diff --git a/.env.example b/.env.example index 17f0f7f4..b9ff57df 100644 --- a/.env.example +++ b/.env.example @@ -84,3 +84,15 @@ RETENTION_DAYS=30 # ─── API ───────────────────────────────────────────────────── PORT=3000 + +# ─── Response cache (Redis) ────────────────────────────────── +# Opt-in caching for the hot read endpoints (/assets/popular, /search). +# Off by default; set CACHE_ENABLED=true to turn it on. +# Clients can force a fresh response with the `X-No-Cache` request header. +CACHE_ENABLED=false +REDIS_URL="redis://localhost:6379" +# Key namespace applied to every cached entry. +CACHE_KEY_PREFIX="wraith:cache:" +# Per-route TTLs in milliseconds. +CACHE_TTL_POPULAR_MS=60000 +CACHE_TTL_SEARCH_MS=15000 diff --git a/package-lock.json b/package-lock.json index ba275487..5dcd827c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "express": "^4.18.3", "express-rate-limit": "^8.3.2", "graphql": "^16.11.0", + "ioredis": "^5.11.1", "parquetjs-lite": "^0.8.7", "ws": "^8.20.0", "zod": "^4.4.3" @@ -555,6 +556,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1094,6 +1096,17 @@ "node": ">=12" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@fast-csv/format": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-5.0.7.tgz", @@ -1177,6 +1190,12 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1903,14 +1922,14 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1924,14 +1943,14 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -1943,7 +1962,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" @@ -2377,6 +2396,7 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3277,6 +3297,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3532,6 +3553,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3769,6 +3799,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4087,6 +4126,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4551,6 +4591,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -4761,6 +4802,51 @@ "integrity": "sha512-a5jlKftS7HUOhkUyYD7j2sJ/ZnvWiNlZS1ldR+g1ifQ+/UuZXIE+YTc/lK1qGj/GwAU5F8Z0e1eVq2t1J5Ob2g==", "license": "BSD-3-Clause" }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -5075,6 +5161,7 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -6623,9 +6710,10 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -6765,6 +6853,27 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7125,6 +7234,12 @@ "node": ">=10" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7531,6 +7646,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7742,6 +7858,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8173,6 +8290,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 50e8ba45..5ca781a8 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "express": "^4.18.3", "express-rate-limit": "^8.3.2", "graphql": "^16.11.0", + "ioredis": "^5.11.1", "parquetjs-lite": "^0.8.7", "ws": "^8.20.0", "zod": "^4.4.3" diff --git a/src/api.ts b/src/api.ts index d226af4b..9a87bff6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,6 +11,7 @@ import { createGraphQLMiddleware } from "./graphql/server"; import { createPopularAssetsRouter } from "./routes/assets/popular"; import { createExportsRouter } from "./routes/exports"; import { createSearchRouter } from "./routes/search"; +import { cacheMiddleware } from "./cache/redis"; import { hostFnQuerySchema, nftOwnerParamsSchema, @@ -31,6 +32,11 @@ const limiter = rateLimit({ message: { error: "Too many requests, please try again later." }, }); +// ── Response cache (opt-in via CACHE_ENABLED) ─────────────────────────────────── +// Per-route TTLs: popular-asset rollups tolerate more staleness than search. +const POPULAR_CACHE_TTL_MS = parseInt(process.env.CACHE_TTL_POPULAR_MS ?? "60000", 10); +const SEARCH_CACHE_TTL_MS = parseInt(process.env.CACHE_TTL_SEARCH_MS ?? "15000", 10); + // ── Amount formatting ───────────────────────────────────────────────────────── const STROOPS = 10_000_000n; @@ -96,13 +102,13 @@ export function createApp(): express.Application { app.use("/graphql", createGraphQLMiddleware()); // ── Assets routes ─────────────────────────────────────────────────────────── - app.use("/assets", createPopularAssetsRouter()); + app.use("/assets", cacheMiddleware({ ttlMs: POPULAR_CACHE_TTL_MS }), createPopularAssetsRouter()); // ── Export routes ───────────────────────────────────────────────────────────── app.use("/", createExportsRouter()); // ── Fuzzy search across accounts, assets, and contracts ────────────────────── - app.use("/search", createSearchRouter()); + app.use("/search", cacheMiddleware({ ttlMs: SEARCH_CACHE_TTL_MS }), createSearchRouter()); // ── Helpers ────────────────────────────────────────────────────────────────── const parseIntParam = (val: unknown, fallback: number): number => { diff --git a/src/cache/__tests__/redis.test.ts b/src/cache/__tests__/redis.test.ts new file mode 100644 index 00000000..55137e40 --- /dev/null +++ b/src/cache/__tests__/redis.test.ts @@ -0,0 +1,246 @@ +import request from "supertest"; +import express, { Request, Response } from "express"; +import { + cacheMiddleware, + cacheConfigFromEnv, + defaultKeyFn, + RedisCache, + type CacheClient, + type CacheConfig, +} from "../redis"; + +// ── In-memory fake of the narrow CacheClient surface ──────────────────────── +class FakeRedis implements CacheClient { + store = new Map(); + getCalls = 0; + setCalls = 0; + + async get(key: string): Promise { + this.getCalls += 1; + return this.store.has(key) ? (this.store.get(key) as string) : null; + } + + async set(key: string, value: string, _mode: "PX", _ttlMs: number): Promise { + this.setCalls += 1; + this.store.set(key, value); + return "OK"; + } + + async del(...keys: string[]): Promise { + for (const k of keys) this.store.delete(k); + return keys.length; + } +} + +const enabledConfig: CacheConfig = { + enabled: true, + redisUrl: "redis://unused", + keyPrefix: "test:", +}; + +/** + * Builds an app whose handler records how many times it actually ran, so a + * cache hit is observable as "origin not invoked". + */ +function buildApp(client: CacheClient | null, config: CacheConfig = enabledConfig) { + const app = express(); + let originHits = 0; + + app.get( + "/widgets", + cacheMiddleware({ ttlMs: 1000, client, config }), + (_req: Request, res: Response) => { + originHits += 1; + res.json({ value: "fresh", originHits }); + }, + ); + + app.get( + "/maybe-error", + cacheMiddleware({ ttlMs: 1000, client, config }), + (req: Request, res: Response) => { + originHits += 1; + res.status(500).json({ error: "boom" }); + }, + ); + + return { app, getOriginHits: () => originHits }; +} + +describe("cacheMiddleware", () => { + it("misses on the first request and runs the origin handler", async () => { + const redis = new FakeRedis(); + const { app, getOriginHits } = buildApp(redis); + + const res = await request(app).get("/widgets").query({ a: "1" }); + + expect(res.status).toBe(200); + expect(res.headers["x-cache"]).toBe("MISS"); + expect(res.body.value).toBe("fresh"); + expect(getOriginHits()).toBe(1); + expect(redis.setCalls).toBe(1); // response written back + }); + + it("serves a hit from cache without re-running the origin handler", async () => { + const redis = new FakeRedis(); + const { app, getOriginHits } = buildApp(redis); + + const first = await request(app).get("/widgets").query({ a: "1" }); + const second = await request(app).get("/widgets").query({ a: "1" }); + + expect(first.headers["x-cache"]).toBe("MISS"); + expect(second.headers["x-cache"]).toBe("HIT"); + // Origin ran exactly once; the second response came from Redis. + expect(getOriginHits()).toBe(1); + expect(second.body).toEqual(first.body); + }); + + it("treats different query strings as different keys", async () => { + const redis = new FakeRedis(); + const { app, getOriginHits } = buildApp(redis); + + await request(app).get("/widgets").query({ a: "1" }); + const other = await request(app).get("/widgets").query({ a: "2" }); + + expect(other.headers["x-cache"]).toBe("MISS"); + expect(getOriginHits()).toBe(2); + }); + + describe("X-No-Cache header", () => { + it("bypasses the read but still refreshes the stored value", async () => { + const redis = new FakeRedis(); + const { app, getOriginHits } = buildApp(redis); + + // Prime the cache. + await request(app).get("/widgets").query({ a: "1" }); + expect(getOriginHits()).toBe(1); + + // Bypass: origin must run again despite the entry existing. + const bypass = await request(app) + .get("/widgets") + .query({ a: "1" }) + .set("X-No-Cache", "1"); + + expect(bypass.headers["x-cache"]).toBe("BYPASS"); + expect(getOriginHits()).toBe(2); + + // The bypass refreshed the entry, so the next plain request is a HIT + // that does not run the origin. + const after = await request(app).get("/widgets").query({ a: "1" }); + expect(after.headers["x-cache"]).toBe("HIT"); + expect(getOriginHits()).toBe(2); + }); + }); + + it("does not cache non-2xx responses", async () => { + const redis = new FakeRedis(); + const { app, getOriginHits } = buildApp(redis); + + const first = await request(app).get("/maybe-error"); + const second = await request(app).get("/maybe-error"); + + expect(first.status).toBe(500); + expect(second.status).toBe(500); + expect(redis.setCalls).toBe(0); + expect(getOriginHits()).toBe(2); // never served from cache + }); + + it("passes through transparently when caching is disabled", async () => { + const redis = new FakeRedis(); + const { app, getOriginHits } = buildApp(null, { ...enabledConfig, enabled: false }); + + const res = await request(app).get("/widgets").query({ a: "1" }); + + expect(res.status).toBe(200); + expect(res.headers["x-cache"]).toBeUndefined(); + expect(getOriginHits()).toBe(1); + expect(redis.setCalls).toBe(0); + }); + + it("falls through to the origin when a cache read throws", async () => { + const flaky: CacheClient = { + get: jest.fn().mockRejectedValue(new Error("connection reset")), + set: jest.fn().mockResolvedValue("OK"), + del: jest.fn().mockResolvedValue(0), + }; + const { app, getOriginHits } = buildApp(flaky); + + const res = await request(app).get("/widgets"); + + expect(res.status).toBe(200); + expect(res.body.value).toBe("fresh"); + expect(getOriginHits()).toBe(1); + }); +}); + +describe("defaultKeyFn", () => { + const baseReq = (query: Record): Request => + ({ method: "GET", baseUrl: "/search", path: "/", query } as unknown as Request); + + it("is stable regardless of query param order", () => { + expect(defaultKeyFn(baseReq({ a: "1", b: "2" }))).toBe( + defaultKeyFn(baseReq({ b: "2", a: "1" })), + ); + }); + + it("encodes method, path and sorted query", () => { + expect(defaultKeyFn(baseReq({ b: "2", a: "1" }))).toBe("GET:/search/?a=1&b=2"); + }); + + it("flattens array query values", () => { + expect(defaultKeyFn(baseReq({ tag: ["x", "y"] }))).toBe("GET:/search/?tag=x,y"); + }); +}); + +describe("RedisCache", () => { + it("round-trips JSON values through get/set", async () => { + const redis = new FakeRedis(); + const cache = new RedisCache(redis, "p:"); + + await cache.set("k", { hello: "world" }, 500); + expect(redis.store.has("p:k")).toBe(true); + expect(await cache.get<{ hello: string }>("k")).toEqual({ hello: "world" }); + }); + + it("returns null for a missing key", async () => { + const cache = new RedisCache(new FakeRedis()); + expect(await cache.get("nope")).toBeNull(); + }); + + it("returns null (a miss) for a corrupt stored value", async () => { + const redis = new FakeRedis(); + redis.store.set("p:bad", "{not json"); + const cache = new RedisCache(redis, "p:"); + expect(await cache.get("bad")).toBeNull(); + }); + + it("deletes a key", async () => { + const redis = new FakeRedis(); + const cache = new RedisCache(redis, "p:"); + await cache.set("k", 1, 500); + await cache.del("k"); + expect(redis.store.has("p:k")).toBe(false); + }); +}); + +describe("cacheConfigFromEnv", () => { + it("is disabled by default", () => { + expect(cacheConfigFromEnv({}).enabled).toBe(false); + }); + + it("enables on CACHE_ENABLED=true or 1", () => { + expect(cacheConfigFromEnv({ CACHE_ENABLED: "true" }).enabled).toBe(true); + expect(cacheConfigFromEnv({ CACHE_ENABLED: "1" }).enabled).toBe(true); + expect(cacheConfigFromEnv({ CACHE_ENABLED: "yes" }).enabled).toBe(false); + }); + + it("reads REDIS_URL and CACHE_KEY_PREFIX", () => { + const cfg = cacheConfigFromEnv({ + CACHE_ENABLED: "true", + REDIS_URL: "redis://example:6379", + CACHE_KEY_PREFIX: "x:", + }); + expect(cfg.redisUrl).toBe("redis://example:6379"); + expect(cfg.keyPrefix).toBe("x:"); + }); +}); diff --git a/src/cache/redis.ts b/src/cache/redis.ts new file mode 100644 index 00000000..41e67dfe --- /dev/null +++ b/src/cache/redis.ts @@ -0,0 +1,195 @@ +import type { Request, Response, NextFunction, RequestHandler } from "express"; +import type { Redis } from "ioredis"; + +/** + * Opt-in Redis response cache. + * + * The hot read endpoints (`/assets/popular`, `/search`) re-run the same handful + * of queries thousands of times an hour for identical query strings. A small + * Redis layer with a per-route TTL collapses those into a single cache hit. + * + * Caching is **off by default**. Set `CACHE_ENABLED=true` (and optionally + * `REDIS_URL`) to turn it on. When disabled — or when Redis is unreachable — the + * middleware degrades to a transparent pass-through so the API never depends on + * the cache being up. + * + * Clients can force a fresh response with the `X-No-Cache` header: the cached + * value is ignored on read but the fresh response is still written back, so the + * next caller benefits. + */ + +// ── Minimal client surface ──────────────────────────────────────────────────── +// We only use these three commands, so the cache (and its tests) depend on this +// narrow interface rather than the full ioredis type. +export interface CacheClient { + get(key: string): Promise; + set(key: string, value: string, mode: "PX", ttlMs: number): Promise; + del(...keys: string[]): Promise; +} + +// ── Config ──────────────────────────────────────────────────────────────────── + +export interface CacheConfig { + enabled: boolean; + redisUrl: string; + /** Prefix applied to every key, namespacing this app's entries. */ + keyPrefix: string; +} + +export function cacheConfigFromEnv(env: NodeJS.ProcessEnv = process.env): CacheConfig { + return { + enabled: env.CACHE_ENABLED === "true" || env.CACHE_ENABLED === "1", + redisUrl: env.REDIS_URL ?? "redis://localhost:6379", + keyPrefix: env.CACHE_KEY_PREFIX ?? "wraith:cache:", + }; +} + +// ── Lazy singleton client ───────────────────────────────────────────────────── + +let sharedClient: CacheClient | null = null; + +/** + * Returns the process-wide Redis client, constructing it on first use. Returns + * `null` when caching is disabled so callers can cheaply skip all cache work. + * + * `ioredis` is imported lazily so environments that never enable caching don't + * pay the connection/setup cost (and tests can run without it installed). + */ +export function getCacheClient(config: CacheConfig = cacheConfigFromEnv()): CacheClient | null { + if (!config.enabled) return null; + if (sharedClient) return sharedClient; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const IORedis = require("ioredis") as { default?: new (url: string) => Redis } & (new (url: string) => Redis); + const Ctor = IORedis.default ?? IORedis; + const redis = new Ctor(config.redisUrl); + // A failed connection must never crash the process — log once and let the + // middleware's try/catch fall through to the origin handler. + redis.on("error", (err: Error) => { + console.error("[cache] redis error:", err.message); + }); + sharedClient = redis as unknown as CacheClient; + return sharedClient; +} + +/** Test seam: inject a fake client (or reset with `null`). */ +export function setCacheClient(client: CacheClient | null): void { + sharedClient = client; +} + +// ── Cache primitive ─────────────────────────────────────────────────────────── + +export class RedisCache { + constructor( + private readonly client: CacheClient, + private readonly keyPrefix = "wraith:cache:", + ) {} + + private prefixed(key: string): string { + return `${this.keyPrefix}${key}`; + } + + async get(key: string): Promise { + const raw = await this.client.get(this.prefixed(key)); + if (raw === null) return null; + try { + return JSON.parse(raw) as T; + } catch { + // A corrupt/foreign value behaves like a miss rather than throwing. + return null; + } + } + + async set(key: string, value: unknown, ttlMs: number): Promise { + await this.client.set(this.prefixed(key), JSON.stringify(value), "PX", ttlMs); + } + + async del(key: string): Promise { + await this.client.del(this.prefixed(key)); + } +} + +// ── Middleware ──────────────────────────────────────────────────────────────── + +export interface CacheMiddlewareOptions { + /** Time-to-live for this route's entries, in milliseconds. */ + ttlMs: number; + /** + * Derives a cache key from the request. Defaults to method + path + a stable, + * sorted serialization of the query string. + */ + keyFn?: (req: Request) => string; + /** Inject a client/config (primarily for tests). */ + client?: CacheClient | null; + config?: CacheConfig; +} + +const HEADER_NO_CACHE = "x-no-cache"; +const HEADER_CACHE_STATUS = "X-Cache"; + +/** Stable key: sorts query params so `?a=1&b=2` and `?b=2&a=1` collide. */ +export function defaultKeyFn(req: Request): string { + const entries = Object.entries(req.query as Record) + .map(([k, v]) => [k, Array.isArray(v) ? v.join(",") : String(v)] as const) + .sort(([a], [b]) => a.localeCompare(b)); + const qs = entries.map(([k, v]) => `${k}=${v}`).join("&"); + return `${req.method}:${req.baseUrl}${req.path}?${qs}`; +} + +/** + * Returns an Express middleware that caches JSON responses for a single route. + * + * - On a hit, replies from Redis and sets `X-Cache: HIT`. + * - On a miss, runs the handler, captures the `res.json(...)` body, writes it + * back with the route's TTL, and sets `X-Cache: MISS`. + * - With the `X-No-Cache` request header, skips the read but still refreshes the + * stored value (`X-Cache: BYPASS`). + * - When caching is disabled or Redis errors, passes straight through. + * + * Only successful (2xx) JSON responses are stored. + */ +export function cacheMiddleware(options: CacheMiddlewareOptions): RequestHandler { + const config = options.config ?? cacheConfigFromEnv(); + const keyFn = options.keyFn ?? defaultKeyFn; + + return async function cache(req: Request, res: Response, next: NextFunction): Promise { + const client = options.client !== undefined ? options.client : getCacheClient(config); + if (!client) { + next(); + return; + } + + const store = new RedisCache(client, config.keyPrefix); + const key = keyFn(req); + const bypassRead = req.header(HEADER_NO_CACHE) !== undefined; + + if (!bypassRead) { + try { + const hit = await store.get(key); + if (hit !== null) { + res.setHeader(HEADER_CACHE_STATUS, "HIT"); + res.json(hit); + return; + } + } catch (err) { + // Read failure → treat as a miss and fall through to the origin. + console.error("[cache] read failed:", (err as Error).message); + } + } + + // Wrap res.json so we can capture the payload on the way out. + const originalJson = res.json.bind(res); + res.json = (body: unknown): Response => { + if (res.statusCode >= 200 && res.statusCode < 300) { + // Fire-and-forget: a write failure must not delay or break the response. + store.set(key, body, options.ttlMs).catch((err: Error) => { + console.error("[cache] write failed:", err.message); + }); + } + return originalJson(body); + }; + + res.setHeader(HEADER_CACHE_STATUS, bypassRead ? "BYPASS" : "MISS"); + next(); + }; +}