diff --git a/package.json b/package.json index e6417780..9244deab 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "i18next-icu": "^2.0.0", "i18next-resources-to-backend": "^1.0.0", "intl-messageformat": "^10.0.0", + "lru-cache": "^10.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^15.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bbff526..fe5fe37e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: intl-messageformat: specifier: ^10.0.0 version: 10.7.18 + lru-cache: + specifier: ^10.0.0 + version: 10.4.3 react: specifier: ^18.2.0 version: 18.3.1 diff --git a/src/shared/api/cache.test.ts b/src/shared/api/cache.test.ts new file mode 100644 index 00000000..556f1a20 --- /dev/null +++ b/src/shared/api/cache.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from "vitest"; +import { + RequestScopedCache, + ClientMemoryCache, + ServerLruCache, + cacheKey, +} from "./cache.js"; + +/** + * lru-cache captures the global `performance` object at module-load time. + * `vi.useFakeTimers()` replaces `globalThis.performance` with a new object, + * so the faked `performance.now()` never reaches lru-cache's internal `perf`. + * + * We work around this by patching `performance.now` in-place via `vi.hoisted` + * (which runs before any imports) and controlling time manually. + */ +const mockNow = vi.hoisted(() => { + let time = 1000; + const original = performance.now.bind(performance); + return { + install: () => { + time = 1000; + performance.now = () => time; + }, + advance: (ms: number) => { + time += ms; + }, + restore: () => { + performance.now = original; + }, + }; +}); + +describe("cacheKey", () => { + it("creates a deterministic key from endpoint, query, and locale", () => { + const k1 = cacheKey("/flights", { date: "2025-01-15", dep: "SVO" }, "ru"); + const k2 = cacheKey("/flights", { date: "2025-01-15", dep: "SVO" }, "ru"); + expect(k1).toBe(k2); + }); + + it("produces different keys for different locales", () => { + const k1 = cacheKey("/flights", { date: "2025-01-15" }, "ru"); + const k2 = cacheKey("/flights", { date: "2025-01-15" }, "en"); + expect(k1).not.toBe(k2); + }); + + it("produces different keys for different queries", () => { + const k1 = cacheKey("/flights", { a: "1" }, "ru"); + const k2 = cacheKey("/flights", { b: "2" }, "ru"); + expect(k1).not.toBe(k2); + }); + + it("sorts query keys for stability", () => { + const k1 = cacheKey("/flights", { b: "2", a: "1" }, "ru"); + const k2 = cacheKey("/flights", { a: "1", b: "2" }, "ru"); + expect(k1).toBe(k2); + }); +}); + +describe("RequestScopedCache", () => { + it("deduplicates concurrent promises for the same key", async () => { + const cache = new RequestScopedCache(); + let callCount = 0; + const factory = () => { + callCount++; + return Promise.resolve("value"); + }; + + const p1 = cache.get("k") ?? (() => { const p = factory(); cache.set("k", p); return p; })(); + const p2 = cache.get("k") ?? (() => { const p = factory(); cache.set("k", p); return p; })(); + + expect(await p1).toBe("value"); + expect(await p2).toBe("value"); + expect(callCount).toBe(1); + }); + + it("returns undefined for unknown keys", () => { + const cache = new RequestScopedCache(); + expect(cache.get("missing")).toBeUndefined(); + }); +}); + +describe("ClientMemoryCache", () => { + it("stores and retrieves values", () => { + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + cache.set("k", "v"); + expect(cache.get("k")).toBe("v"); + }); + + it("evicts after TTL expires", () => { + mockNow.install(); + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 100 }); + cache.set("k", "v"); + expect(cache.get("k")).toBe("v"); + + mockNow.advance(101); + expect(cache.get("k")).toBeUndefined(); + mockNow.restore(); + }); + + it("supports per-entry TTL override", () => { + mockNow.install(); + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + cache.set("k", "v", 50); + mockNow.advance(51); + expect(cache.get("k")).toBeUndefined(); + mockNow.restore(); + }); + + it("evicts LRU when max count reached", () => { + const cache = new ClientMemoryCache({ max: 2, defaultTtlMs: 60000 }); + cache.set("a", "1"); + cache.set("b", "2"); + cache.set("c", "3"); + expect(cache.get("a")).toBeUndefined(); + expect(cache.get("b")).toBe("2"); + expect(cache.get("c")).toBe("3"); + }); + + it("tracks size", () => { + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + expect(cache.size).toBe(0); + cache.set("k", "v"); + expect(cache.size).toBe(1); + cache.delete("k"); + expect(cache.size).toBe(0); + }); + + it("clear() removes all entries", () => { + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + cache.set("a", "1"); + cache.set("b", "2"); + cache.clear(); + expect(cache.size).toBe(0); + }); +}); + +describe("ServerLruCache", () => { + it("stores and retrieves values", () => { + const cache = new ServerLruCache({ + maxBytes: 1024, + defaultTtlMs: 5000, + }); + cache.set("k", "hello"); + expect(cache.get("k")).toBe("hello"); + }); + + it("evicts when byte cap is exceeded", () => { + const cache = new ServerLruCache({ + maxBytes: 50, + defaultTtlMs: 60000, + }); + // Each string is ~5-10 bytes when JSON.stringify'd, but the key is also counted + cache.set("a", "x".repeat(20)); + cache.set("b", "y".repeat(20)); + cache.set("c", "z".repeat(20)); + // At least one of the earlier entries should be evicted + const remaining = [cache.get("a"), cache.get("b"), cache.get("c")].filter(Boolean); + expect(remaining.length).toBeLessThan(3); + }); + + it("tracks calculatedSize", () => { + const cache = new ServerLruCache({ + maxBytes: 10000, + defaultTtlMs: 5000, + }); + expect(cache.calculatedSize).toBe(0); + cache.set("k", "hello"); + expect(cache.calculatedSize).toBeGreaterThan(0); + }); + + it("evicts after TTL expires", () => { + mockNow.install(); + const cache = new ServerLruCache({ + maxBytes: 10000, + defaultTtlMs: 100, + }); + cache.set("k", "v"); + expect(cache.get("k")).toBe("v"); + mockNow.advance(101); + expect(cache.get("k")).toBeUndefined(); + mockNow.restore(); + }); + + it("supports custom sizeCalculation", () => { + const cache = new ServerLruCache<{ data: string }>({ + maxBytes: 100, + defaultTtlMs: 5000, + sizeCalculation: (value) => value.data.length, + }); + cache.set("k", { data: "x".repeat(50) }); + expect(cache.calculatedSize).toBe(50); + }); +}); diff --git a/src/shared/api/cache.ts b/src/shared/api/cache.ts new file mode 100644 index 00000000..c4ed9524 --- /dev/null +++ b/src/shared/api/cache.ts @@ -0,0 +1,111 @@ +import { LRUCache } from "lru-cache"; +import type { Language } from "@/i18n/resolver"; + +// --- Cache key convention --- + +export function cacheKey( + endpoint: string, + query: Record, + locale: Language, +): string { + const sortedQuery = Object.keys(query) + .sort() + .map((k) => `${k}=${String(query[k])}`) + .join("&"); + return `${locale}:${endpoint}?${sortedQuery}`; +} + +// --- (1) Request-scoped SSR dedup cache --- + +export class RequestScopedCache { + private readonly store = new Map>(); + + get(key: string): Promise | undefined { + return this.store.get(key) as Promise | undefined; + } + + set(key: string, promise: Promise): void { + this.store.set(key, promise); + } +} + +// --- (2) Client-side per-tab in-memory TTL cache --- + +export class ClientMemoryCache> { + private readonly cache: LRUCache; + + constructor(options: { max: number; defaultTtlMs: number }) { + this.cache = new LRUCache({ + max: options.max, + ttl: options.defaultTtlMs, + ttlResolution: 0, + }); + } + + get(key: string): T | undefined { + return this.cache.get(key); + } + + set(key: string, value: T, ttlMs?: number): void { + this.cache.set(key, value, ttlMs !== undefined ? { ttl: ttlMs } : undefined); + } + + delete(key: string): void { + this.cache.delete(key); + } + + clear(): void { + this.cache.clear(); + } + + get size(): number { + return this.cache.size; + } +} + +// --- (3) Shared per-VM LRU with byte cap --- + +export class ServerLruCache> { + private readonly cache: LRUCache; + + constructor(options: { + maxBytes: number; + defaultTtlMs: number; + sizeCalculation?: (value: T, key: string) => number; + }) { + this.cache = new LRUCache({ + maxSize: options.maxBytes, + ttl: options.defaultTtlMs, + ttlResolution: 0, + sizeCalculation: + options.sizeCalculation ?? + ((value: T) => { + try { + return JSON.stringify(value).length; + } catch { + return 1024; // fallback for non-serializable values + } + }), + }); + } + + get(key: string): T | undefined { + return this.cache.get(key); + } + + set(key: string, value: T, ttlMs?: number): void { + this.cache.set(key, value, ttlMs !== undefined ? { ttl: ttlMs } : undefined); + } + + delete(key: string): void { + this.cache.delete(key); + } + + clear(): void { + this.cache.clear(); + } + + get calculatedSize(): number { + return this.cache.calculatedSize; + } +}