Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
36 changes: 22 additions & 14 deletions apps/api/src/deps.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -15,6 +15,7 @@ import {
REDIS_SESSION_TTL_SECONDS,
REDIS_URL,
residentialProxyPool,
SESSION_CACHE_DRIVER,
STALL_TIMEOUT_MS,
} from "./config"

Expand Down Expand Up @@ -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
Expand Down
40 changes: 36 additions & 4 deletions apps/docs/architecture/session-cache.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
18 changes: 18 additions & 0 deletions apps/docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
74 changes: 74 additions & 0 deletions packages/browser/src/memory-session.ts
Original file line number Diff line number Diff line change
@@ -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<string, Entry>()
private ttl: number

constructor({ ttlSeconds }: { ttlSeconds: number }) {
this.ttl = ttlSeconds
}

async connect(): Promise<void> {
// 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<void> {
this.store.set(this.key(domain), {
data,
expiresAt: Date.now() + this.ttl * 1000,
})
}

async load(domain: string): Promise<SessionData | undefined> {
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<void> {
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
}
}
10 changes: 9 additions & 1 deletion packages/browser/src/session.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import type { SessionData } from "@trawl/types"
import { RedisClient } from "bun"

export class SessionCache {
export interface ISessionCache {
connect(timeoutMs?: number): Promise<void>
close(): void
save(domain: string, data: SessionData): Promise<void>
load(domain: string): Promise<SessionData | undefined>
invalidate(domain: string): Promise<void>
}

export class SessionCache implements ISessionCache {
private redis: RedisClient
private ttl: number

Expand Down
78 changes: 78 additions & 0 deletions packages/browser/tests/memory-session.test.ts
Original file line number Diff line number Diff line change
@@ -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)")
})