From aa85e93f8814b6707c2b1947adc1360fad203e88 Mon Sep 17 00:00:00 2001 From: Nathan Mittelette Date: Tue, 1 Sep 2026 21:05:30 +0200 Subject: [PATCH] feat: pluggable session cache driver (Redis or in-memory) Add SESSION_CACHE_DRIVER env var to select between Redis (default, shared across instances) and an in-process Map cache (zero dependencies, single- instance only). Introduces ISessionCache interface for swappable backends. Closes #117 --- CHANGELOG.md | 3 + apps/api/src/config.ts | 3 + apps/api/src/deps.ts | 36 +++++---- apps/docs/architecture/session-cache.md | 40 +++++++++- apps/docs/getting-started/configuration.md | 18 +++++ packages/browser/src/index.ts | 3 +- packages/browser/src/memory-session.ts | 74 ++++++++++++++++++ packages/browser/src/session.ts | 10 ++- packages/browser/tests/memory-session.test.ts | 78 +++++++++++++++++++ 9 files changed, 245 insertions(+), 20 deletions(-) create mode 100644 packages/browser/src/memory-session.ts create mode 100644 packages/browser/tests/memory-session.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64456af..b9b2f64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Pluggable session cache driver selectable via `SESSION_CACHE_DRIVER` (`redis` default, `memory` for single-instance deployments). Introduces `ISessionCache` interface and `MemorySessionCache` implementation (#117). + ## [1.5.0] - 2026-09-04 ### Changed diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 2eca2ca..8fd418f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -1,6 +1,9 @@ import { ProxyPool } from "@trawl/tiers" export const REDIS_URL = process.env.REDIS_URL?.trim() || undefined +// Session cache driver: "redis" (default, shared across instances) or +// "memory" (in-process Map, zero dependencies, per-instance only). +export const SESSION_CACHE_DRIVER = (process.env.SESSION_CACHE_DRIVER ?? "redis").toLowerCase() as "redis" | "memory" const integerInRange = (value: string | undefined, fallback: number, min: number, max = Number.MAX_SAFE_INTEGER) => { if (value === undefined || value.trim() === "") return fallback const parsed = Number(value) diff --git a/apps/api/src/deps.ts b/apps/api/src/deps.ts index 77b12f5..18565ca 100644 --- a/apps/api/src/deps.ts +++ b/apps/api/src/deps.ts @@ -1,4 +1,4 @@ -import { BrowserPool, SessionCache } from "@trawl/browser" +import { BrowserPool, MemorySessionCache, SessionCache } from "@trawl/browser" import type { AcquireOptions, OrchestratorDeps } from "@trawl/tiers" import type { SessionData } from "@trawl/types" import { @@ -15,6 +15,7 @@ import { REDIS_SESSION_TTL_SECONDS, REDIS_URL, residentialProxyPool, + SESSION_CACHE_DRIVER, STALL_TIMEOUT_MS, } from "./config" @@ -94,21 +95,28 @@ export class SessionCacheRecovery { } const redisUrl = REDIS_URL -const sessionCacheRecovery = redisUrl +const sessionCacheRecovery = SESSION_CACHE_DRIVER === "memory" ? new SessionCacheRecovery({ - createCache: () => new SessionCache({ redisUrl, ttlSeconds: REDIS_SESSION_TTL_SECONDS }), - connectTimeoutMs: REDIS_CONNECT_TIMEOUT_MS, - retryDelayMs: REDIS_RETRY_DELAY_MS, - onConnected: () => console.log("[api] session cache connected (Tier 2 fast-path enabled)"), - onUnavailable: (err) => { - const retry = REDIS_RETRY_DELAY_MS > 0 ? `; retrying in ${REDIS_RETRY_DELAY_MS}ms` : "" - console.warn( - `[api] session cache unavailable — Tier 2 disabled${retry}:`, - err instanceof Error ? err.message : err, - ) - }, + createCache: () => new MemorySessionCache({ ttlSeconds: REDIS_SESSION_TTL_SECONDS }), + connectTimeoutMs: 0, + retryDelayMs: 0, + onConnected: () => console.log("[api] session cache: memory (Tier 2 fast-path enabled, per-instance)"), }) - : undefined + : redisUrl + ? new SessionCacheRecovery({ + createCache: () => new SessionCache({ redisUrl, ttlSeconds: REDIS_SESSION_TTL_SECONDS }), + connectTimeoutMs: REDIS_CONNECT_TIMEOUT_MS, + retryDelayMs: REDIS_RETRY_DELAY_MS, + onConnected: () => console.log("[api] session cache connected (Tier 2 fast-path enabled)"), + onUnavailable: (err) => { + const retry = REDIS_RETRY_DELAY_MS > 0 ? `; retrying in ${REDIS_RETRY_DELAY_MS}ms` : "" + console.warn( + `[api] session cache unavailable — Tier 2 disabled${retry}:`, + err instanceof Error ? err.message : err, + ) + }, + }) + : undefined interface InitPoolOptions { poolSize?: number diff --git a/apps/docs/architecture/session-cache.md b/apps/docs/architecture/session-cache.md index 1ff4078..719ddeb 100644 --- a/apps/docs/architecture/session-cache.md +++ b/apps/docs/architecture/session-cache.md @@ -1,13 +1,36 @@ --- title: Session Cache -description: How TRAWL caches solved browser sessions in Redis to avoid unnecessary challenge work. +description: How TRAWL caches solved browser sessions to avoid unnecessary challenge work, with a pluggable Redis or in-memory driver. --- # Session Cache The session cache is what makes Tier 2 possible. After a successful Tier 3 or Tier 4 solve, TRAWL -saves the extracted cookies and browser user agent in Redis. The next request to the same hostname -injects that state into a browser context and attempts to reuse the solved session. +saves the extracted cookies and browser user agent in the configured cache backend. The next request +to the same hostname injects that state into a browser context and attempts to reuse the solved +session. + +## Cache driver + +TRAWL supports two session cache drivers, selected at startup via `SESSION_CACHE_DRIVER`: + +| Driver | Value | Shared across instances | External dependencies | +| --- | --- | --- | --- | +| Redis (default) | `redis` | Yes | Redis 8.8 | +| In-memory | `memory` | No (per-process) | None | + +```ini +SESSION_CACHE_DRIVER=redis # default — shared across instances +SESSION_CACHE_DRIVER=memory # in-process Map, zero dependencies +``` + +Use `redis` when running multiple API instances behind a load balancer — sessions solved on one +instance are visible to all others. Use `memory` for single-instance deployments where the +operational overhead of Redis is not justified; sessions are scoped to the process and lost on +restart. + +Both drivers implement the `ISessionCache` interface, so additional backends (e.g. SQLite, Valkey, +KeyDB) can be added without touching the orchestrator or tier logic. ## Storage format @@ -61,7 +84,7 @@ This handles provider cookies expiring or being rejected before the Redis TTL en ## Redis -TRAWL's cache backend is Redis 8.8. TRAWL talks to it with `new RedisClient(REDIS_URL)` from Bun's native Redis client (not ioredis). +When `SESSION_CACHE_DRIVER=redis` (the default), TRAWL talks to Redis 8.8 with `new RedisClient(REDIS_URL)` from Bun's native Redis client (not ioredis). The cache is optional. When `REDIS_URL` is empty or unset, TRAWL does not create a Redis client and Tier 2 remains disabled. @@ -78,3 +101,12 @@ const redis = new RedisClient('redis://localhost:6379') await redis.set('session:example.com', JSON.stringify(data), 'EX', 3600) const raw = await redis.get('session:example.com') ``` + +## In-memory + +When `SESSION_CACHE_DRIVER=memory`, TRAWL uses an in-process `Map` with TTL-based expiry. There is +no `connect()` step and no network I/O — the cache is available immediately on startup. Entries +are lazily expired on read and can be proactively pruned via `MemorySessionCache.prune()`. + +Because the cache lives in the API process, sessions are **not shared** across instances. A solve +on instance A is invisible to instance B. Use this driver only for single-instance deployments. diff --git a/apps/docs/getting-started/configuration.md b/apps/docs/getting-started/configuration.md index e95cbdc..c422eef 100644 --- a/apps/docs/getting-started/configuration.md +++ b/apps/docs/getting-started/configuration.md @@ -34,6 +34,24 @@ match an entry in this list. MCP_ALLOWED_ORIGINS=https://chat.example.com,https://admin.example.com ``` +## Session Cache Driver + +### `SESSION_CACHE_DRIVER` + +**Default:** `redis` + +Selects the backend used for the Tier 2 session cache. Redis remains the default to preserve +cross-instance session sharing and backward compatibility. + +```ini +SESSION_CACHE_DRIVER=redis # default — shared across instances, requires Redis +SESSION_CACHE_DRIVER=memory # in-process Map, zero external dependencies +``` + +Use `memory` for single-instance deployments where running Redis is not justified. Sessions are +scoped to the API process and lost on restart — they are **not shared** across instances. See +[Session Cache](/architecture/session-cache) for details. + ## Redis ### `REDIS_URL` diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index a89561e..857d663 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -2,4 +2,5 @@ export { FINGERPRINT, FINGERPRINT_POOL } from "./fingerprint" export type { BrowserHandle } from "./pool" export { BrowserPool, closeTemporaryContext, newFreshContext, PoolExhaustedError } from "./pool" export { type PlaywrightProxy, toPlaywrightProxy } from "./proxy" -export { SessionCache } from "./session" +export { SessionCache, type ISessionCache } from "./session" +export { MemorySessionCache } from "./memory-session" diff --git a/packages/browser/src/memory-session.ts b/packages/browser/src/memory-session.ts new file mode 100644 index 0000000..465a13d --- /dev/null +++ b/packages/browser/src/memory-session.ts @@ -0,0 +1,74 @@ +import type { SessionData } from "@trawl/types" +import type { ISessionCache } from "./session" + +interface Entry { + data: SessionData + expiresAt: number +} + +/** + * In-process session cache backed by a plain Map. Zero external dependencies, + * zero network latency. Sessions are scoped to this process — if you run + * multiple API instances behind a load balancer, each instance keeps its own + * independent cache and a solve on instance A is NOT visible to instance B. + * Use the Redis driver when cross-instance sharing is required. + */ +export class MemorySessionCache implements ISessionCache { + private store = new Map() + private ttl: number + + constructor({ ttlSeconds }: { ttlSeconds: number }) { + this.ttl = ttlSeconds + } + + async connect(): Promise { + // No-op — nothing to connect to. + } + + close(): void { + // No-op — nothing to close. + } + + private key(domain: string): string { + return `session:${domain}` + } + + async save(domain: string, data: SessionData): Promise { + this.store.set(this.key(domain), { + data, + expiresAt: Date.now() + this.ttl * 1000, + }) + } + + async load(domain: string): Promise { + const entry = this.store.get(this.key(domain)) + if (!entry) return + if (Date.now() >= entry.expiresAt) { + this.store.delete(this.key(domain)) + return + } + return entry.data + } + + async invalidate(domain: string): Promise { + this.store.delete(this.key(domain)) + } + + /** Remove all expired entries. Call periodically if the workload is + * high-churn and you want to bound memory growth. */ + prune(): number { + let removed = 0 + const now = Date.now() + for (const [key, entry] of this.store) { + if (now >= entry.expiresAt) { + this.store.delete(key) + removed++ + } + } + return removed + } + + get size(): number { + return this.store.size + } +} diff --git a/packages/browser/src/session.ts b/packages/browser/src/session.ts index d89a490..9a9adab 100644 --- a/packages/browser/src/session.ts +++ b/packages/browser/src/session.ts @@ -1,7 +1,15 @@ import type { SessionData } from "@trawl/types" import { RedisClient } from "bun" -export class SessionCache { +export interface ISessionCache { + connect(timeoutMs?: number): Promise + close(): void + save(domain: string, data: SessionData): Promise + load(domain: string): Promise + invalidate(domain: string): Promise +} + +export class SessionCache implements ISessionCache { private redis: RedisClient private ttl: number diff --git a/packages/browser/tests/memory-session.test.ts b/packages/browser/tests/memory-session.test.ts new file mode 100644 index 0000000..609e9c0 --- /dev/null +++ b/packages/browser/tests/memory-session.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test" +import { MemorySessionCache } from "../src/memory-session" +import type { SessionData } from "@trawl/types" + +const cookie: SessionData = { + cookies: [ + { + name: "cf_clearance", + value: "abc123", + domain: ".example.com", + path: "/", + expires: Date.now() / 1000 + 3600, + httpOnly: true, + secure: true, + }, + ], + userAgent: "Mozilla/5.0", + savedAt: Date.now(), +} + +test("MemorySessionCache.connect() resolves instantly", async () => { + const cache = new MemorySessionCache({ ttlSeconds: 60 }) + await expect(cache.connect()).resolves.toBeUndefined() +}) + +test("MemorySessionCache saves and loads by domain", async () => { + const cache = new MemorySessionCache({ ttlSeconds: 60 }) + await cache.save("example.com", cookie) + const loaded = await cache.load("example.com") + expect(loaded).toEqual(cookie) +}) + +test("MemorySessionCache returns undefined for unknown domain", async () => { + const cache = new MemorySessionCache({ ttlSeconds: 60 }) + const loaded = await cache.load("nope.com") + expect(loaded).toBeUndefined() +}) + +test("MemorySessionCache invalidate removes the entry", async () => { + const cache = new MemorySessionCache({ ttlSeconds: 60 }) + await cache.save("example.com", cookie) + await cache.invalidate("example.com") + const loaded = await cache.load("example.com") + expect(loaded).toBeUndefined() +}) + +test("MemorySessionCache expires entries after TTL", async () => { + const cache = new MemorySessionCache({ ttlSeconds: 1 }) + await cache.save("example.com", cookie) + expect(await cache.load("example.com")).toEqual(cookie) + + await new Promise((r) => setTimeout(r, 1100)) + const loaded = await cache.load("example.com") + expect(loaded).toBeUndefined() +}) + +test("MemorySessionCache.prune removes only expired entries", async () => { + const cache = new MemorySessionCache({ ttlSeconds: 1 }) + await cache.save("a.com", cookie) + await cache.save("b.com", cookie) + + await new Promise((r) => setTimeout(r, 1100)) + await cache.save("c.com", cookie) + + const removed = cache.prune() + expect(removed).toBe(2) + expect(cache.size).toBe(1) + expect(await cache.load("c.com")).toEqual(cookie) +}) + +test("MemorySessionCache overwrites on re-save", async () => { + const cache = new MemorySessionCache({ ttlSeconds: 60 }) + await cache.save("example.com", cookie) + const updated: SessionData = { ...cookie, userAgent: "Mozilla/5.0 (updated)" } + await cache.save("example.com", updated) + const loaded = await cache.load("example.com") + expect(loaded?.userAgent).toBe("Mozilla/5.0 (updated)") +})