From 09f600ea739824a3ba20b98e661e29dd0a92c275 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 27 Apr 2026 02:34:25 -0400 Subject: [PATCH 1/2] feat: add TTL cache utility --- src/utils/cache.ts | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/utils/cache.ts diff --git a/src/utils/cache.ts b/src/utils/cache.ts new file mode 100644 index 0000000..fc636ce --- /dev/null +++ b/src/utils/cache.ts @@ -0,0 +1,81 @@ +export interface CacheEntry { + value: T; + expiresAt: number; +} + +export class TtlCache { + private store = new Map>(); + private maxSize: number; + private defaultTtlMs: number; + + constructor(maxSize = 1000, defaultTtlMs = 300000) { + this.maxSize = maxSize; + this.defaultTtlMs = defaultTtlMs; + } + + get(key: string): T | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + return entry.value; + } + + set(key: string, value: T, ttlMs?: number): void { + if (this.store.size >= this.maxSize) { + this.evict(); + } + this.store.set(key, { + value, + expiresAt: Date.now() + (ttlMs || this.defaultTtlMs), + }); + } + + has(key: string): boolean { + return this.store.has(key); + } + + delete(key: string): boolean { + return this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } + + get size(): number { + return this.store.size; + } + + private evict(): void { + let oldestKey: string = ""; + let oldestTime = Infinity; + + for (const [key, entry] of this.store) { + if (entry.expiresAt < oldestTime) { + oldestTime = entry.expiresAt; + oldestKey = key; + } + } + + if (oldestKey) this.store.delete(oldestKey); + } +} + +export function parseCacheControl(header: string): { maxAge: number; noCache: boolean } { + const result = { maxAge: 0, noCache: false }; + + for (const directive of header.split(",")) { + const trimmed = directive.trim().toLowerCase(); + if (trimmed === "no-cache" || trimmed === "no-store") { + result.noCache = true; + } + if (trimmed.startsWith("max-age=")) { + result.maxAge = Number(trimmed.split("=")[1]) * 1000; + } + } + + return result; +} From 0662662285bd45b84f0fb8faf238331e668021d7 Mon Sep 17 00:00:00 2001 From: "pi-relay[bot]" Date: Mon, 27 Apr 2026 06:40:43 +0000 Subject: [PATCH 2/2] fix: address AI review findings --- package-lock.json | 69 ++----------------------- src/utils/cache.ts | 13 +++-- test/utils/cache.test.ts | 109 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 69 deletions(-) create mode 100644 test/utils/cache.test.ts diff --git a/package-lock.json b/package-lock.json index 37ab73e..aef8f04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "dependencies": { "yaml": "^2.8.3" }, + "bin": { + "relay": "dist/cli/main.js" + }, "devDependencies": { "@biomejs/biome": "^2.3.5", "@mariozechner/pi-agent-core": "^0.69.0", @@ -886,9 +889,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -906,9 +906,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -926,9 +923,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -946,9 +940,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1555,9 +1546,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1575,9 +1563,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1595,9 +1580,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1615,9 +1597,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1635,9 +1614,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1972,9 +1948,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1989,9 +1962,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2006,9 +1976,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2023,9 +1990,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2040,9 +2004,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2057,9 +2018,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2074,9 +2032,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2091,9 +2046,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2108,9 +2060,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2125,9 +2074,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2142,9 +2088,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2159,9 +2102,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2176,9 +2116,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/utils/cache.ts b/src/utils/cache.ts index fc636ce..53e09f2 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -29,12 +29,18 @@ export class TtlCache { } this.store.set(key, { value, - expiresAt: Date.now() + (ttlMs || this.defaultTtlMs), + expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs), }); } has(key: string): boolean { - return this.store.has(key); + const entry = this.store.get(key); + if (!entry) return false; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return false; + } + return true; } delete(key: string): boolean { @@ -73,7 +79,8 @@ export function parseCacheControl(header: string): { maxAge: number; noCache: bo result.noCache = true; } if (trimmed.startsWith("max-age=")) { - result.maxAge = Number(trimmed.split("=")[1]) * 1000; + const parsed = Number(trimmed.split("=")[1]); + if (Number.isFinite(parsed)) result.maxAge = parsed * 1000; } } diff --git a/test/utils/cache.test.ts b/test/utils/cache.test.ts new file mode 100644 index 0000000..c516dfa --- /dev/null +++ b/test/utils/cache.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseCacheControl, TtlCache } from "../../src/utils/cache.js"; + +describe("TtlCache", () => { + it("stores and retrieves values", () => { + const cache = new TtlCache(); + cache.set("key", "value"); + expect(cache.get("key")).toBe("value"); + }); + + it("returns undefined for missing keys", () => { + const cache = new TtlCache(); + expect(cache.get("missing")).toBeUndefined(); + }); + + it("expires entries based on TTL", () => { + vi.useFakeTimers(); + const cache = new TtlCache(100, 1000); + cache.set("key", "value"); + expect(cache.get("key")).toBe("value"); + + vi.advanceTimersByTime(999); + expect(cache.get("key")).toBe("value"); + + vi.advanceTimersByTime(2); + expect(cache.get("key")).toBeUndefined(); + vi.useRealTimers(); + }); + + it("has() returns false for expired entries", () => { + vi.useFakeTimers(); + const cache = new TtlCache(100, 500); + cache.set("key", "value"); + + vi.advanceTimersByTime(501); + expect(cache.has("key")).toBe(false); + expect(cache.get("key")).toBeUndefined(); + vi.useRealTimers(); + }); + + it("has() returns true for non-expired entries", () => { + const cache = new TtlCache(); + cache.set("key", "value"); + expect(cache.has("key")).toBe(true); + }); + + it("has() returns false for missing keys", () => { + const cache = new TtlCache(); + expect(cache.has("missing")).toBe(false); + }); + + it("evicts oldest entry when max size is reached", () => { + const cache = new TtlCache(2, 10000); + cache.set("a", "1"); + cache.set("b", "2"); + cache.set("c", "3"); + + // One entry should have been evicted + expect(cache.size).toBeLessThanOrEqual(2); + expect(cache.get("c")).toBe("3"); + }); + + it("respects ttlMs=0 as immediate expiry", () => { + vi.useFakeTimers(); + const cache = new TtlCache(100, 5000); + cache.set("key", "value", 0); + + // Even 1ms later, it should be expired + vi.advanceTimersByTime(1); + expect(cache.get("key")).toBeUndefined(); + vi.useRealTimers(); + }); + + it("supports delete and clear", () => { + const cache = new TtlCache(); + cache.set("a", "1"); + cache.set("b", "2"); + expect(cache.delete("a")).toBe(true); + expect(cache.get("a")).toBeUndefined(); + cache.clear(); + expect(cache.size).toBe(0); + }); +}); + +describe("parseCacheControl", () => { + it("parses max-age directive", () => { + expect(parseCacheControl("max-age=60")).toEqual({ maxAge: 60000, noCache: false }); + }); + + it("parses no-cache directive", () => { + expect(parseCacheControl("no-cache")).toEqual({ maxAge: 0, noCache: true }); + }); + + it("parses no-store directive", () => { + expect(parseCacheControl("no-store")).toEqual({ maxAge: 0, noCache: true }); + }); + + it("parses combined directives", () => { + expect(parseCacheControl("max-age=30, no-cache")).toEqual({ maxAge: 30000, noCache: true }); + }); + + it("ignores malformed max-age values", () => { + expect(parseCacheControl("max-age=abc")).toEqual({ maxAge: 0, noCache: false }); + }); + + it("returns defaults for empty string", () => { + expect(parseCacheControl("")).toEqual({ maxAge: 0, noCache: false }); + }); +});