plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
2 changed files with 140 additions and 0 deletions
Showing only changes of commit 04c5432aef - Show all commits
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { CachedApiClient } from "./cached-client.js";
import type { ApiClient } from "./client.js";
import { RequestScopedCache, ClientMemoryCache } from "./cache.js";
function mockClient(): ApiClient {
return {
get: vi.fn(async () => ({ data: "fresh" })),
post: vi.fn(async () => ({ data: "posted" })),
} as unknown as ApiClient;
}
describe("CachedApiClient", () => {
it("delegates to the underlying client on cache miss", async () => {
const client = mockClient();
const cached = new CachedApiClient({ client });
const result = await cached.get("/flights");
expect(result).toEqual({ data: "fresh" });
expect(client.get).toHaveBeenCalledTimes(1);
});
it("returns cached value from request-scoped cache on hit", async () => {
const client = mockClient();
const requestScoped = new RequestScopedCache();
const cached = new CachedApiClient({ client, requestScoped });
// First call populates the cache
await cached.get("/flights");
// Second call should hit the request-scoped cache
await cached.get("/flights");
expect(client.get).toHaveBeenCalledTimes(1);
});
it("returns cached value from client-memory cache on hit", async () => {
const client = mockClient();
const clientMemory = new ClientMemoryCache<NonNullable<unknown>>({ max: 10, defaultTtlMs: 5000 });
const cached = new CachedApiClient({ client, clientMemory });
await cached.get("/flights");
await cached.get("/flights");
expect(client.get).toHaveBeenCalledTimes(1);
});
it("cache key differs by query params", async () => {
const client = mockClient();
const clientMemory = new ClientMemoryCache<NonNullable<unknown>>({ max: 10, defaultTtlMs: 5000 });
const cached = new CachedApiClient({ client, clientMemory });
await cached.get("/flights", { date: "2025-01-15" });
await cached.get("/flights", { date: "2025-01-16" });
expect(client.get).toHaveBeenCalledTimes(2);
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { ApiClient } from "./client.js";
import type { RequestScopedCache, ClientMemoryCache, ServerLruCache } from "./cache.js";
export interface CachedClientOptions {
client: ApiClient;
requestScoped?: RequestScopedCache | undefined;
clientMemory?: ClientMemoryCache<NonNullable<unknown>> | undefined;
serverLru?: ServerLruCache<NonNullable<unknown>> | undefined;
ttlMs?: number | undefined;
}
export class CachedApiClient {
private readonly client: ApiClient;
private readonly requestScoped: RequestScopedCache | undefined;
private readonly clientMemory: ClientMemoryCache<NonNullable<unknown>> | undefined;
private readonly serverLru: ServerLruCache<NonNullable<unknown>> | undefined;
private readonly ttlMs: number | undefined;
constructor(options: CachedClientOptions) {
this.client = options.client;
this.requestScoped = options.requestScoped;
this.clientMemory = options.clientMemory;
this.serverLru = options.serverLru;
this.ttlMs = options.ttlMs;
}
async get<T>(
path: string,
query?: Record<string, string | number | boolean>,
): Promise<T> {
const key = this.buildKey(path, query);
// (1) Check request-scoped cache (SSR dedup)
if (this.requestScoped) {
const cached = this.requestScoped.get<T>(key);
if (cached) return cached;
}
// (2) Check client-memory cache
if (this.clientMemory) {
const cached = this.clientMemory.get(key) as T | undefined;
if (cached !== undefined) return cached;
}
// (3) Check server LRU cache
if (this.serverLru) {
const cached = this.serverLru.get(key) as T | undefined;
if (cached !== undefined) return cached;
}
// Cache miss — fetch from upstream
const promise = this.client.get<T>(path, query);
// Store the in-flight promise in request-scoped cache for dedup
if (this.requestScoped) {
this.requestScoped.set(key, promise);
}
const result = await promise;
// Populate longer-lived caches with the resolved value
if (this.clientMemory) {
this.clientMemory.set(key, result as NonNullable<unknown>, this.ttlMs);
}
if (this.serverLru) {
this.serverLru.set(key, result as NonNullable<unknown>, this.ttlMs);
}
return result;
}
private buildKey(
path: string,
query?: Record<string, string | number | boolean>,
): string {
const q = query
? Object.keys(query)
.sort()
.map((k) => `${k}=${String(query[k])}`)
.join("&")
: "";
return `${path}?${q}`;
}
}