diff --git a/.changeset/valkey-cache-backend.md b/.changeset/valkey-cache-backend.md new file mode 100644 index 000000000..f5c34e51c --- /dev/null +++ b/.changeset/valkey-cache-backend.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": minor +--- + +Add Valkey as an optional cache backend via iovalkey. Configure with `valkeyHost` (and optional `valkeyPort`, `valkeyTls`, `valkeyPassword`, `valkeyUsername`, `cacheTtl`, `valkeyKeyPrefix`, `valkeyRequestTimeout`, `valkeyMaxCacheValueBytes`) to store act/agent cache entries in Valkey instead of the local filesystem. Gracefully falls back to disabled caching if the connection fails. diff --git a/packages/core/lib/v3/cache/ActCache.ts b/packages/core/lib/v3/cache/ActCache.ts index 656f3c598..45752b5c6 100644 --- a/packages/core/lib/v3/cache/ActCache.ts +++ b/packages/core/lib/v3/cache/ActCache.ts @@ -80,7 +80,10 @@ export class ActCache { value: entry, error, path, - } = await this.storage.readJson(`${context.cacheKey}.json`); + } = await this.storage.readJson( + `${context.cacheKey}.json`, + "act", + ); if (error && path) { this.logger({ category: "cache", @@ -161,6 +164,7 @@ export class ActCache { const { error, path } = await this.storage.writeJson( `${context.cacheKey}.json`, entry, + "act", ); if (error && path) { this.logger({ @@ -334,6 +338,7 @@ export class ActCache { ...entry, variableKeys: context.variableKeys, }, + "act", ); if (error && path) { diff --git a/packages/core/lib/v3/cache/AgentCache.ts b/packages/core/lib/v3/cache/AgentCache.ts index 4d78b8dfc..3f93b34d0 100644 --- a/packages/core/lib/v3/cache/AgentCache.ts +++ b/packages/core/lib/v3/cache/AgentCache.ts @@ -185,6 +185,12 @@ export class AgentCache { path, } = await this.storage.readJson( `agent-${context.cacheKey}.json`, + // NOTE: The `category` parameter provides act/agent namespace isolation only for + // the Valkey backend (via key prefix). File and in-memory backends ignore it and + // store entries at `agent-${cacheKey}.json` — collisions don't occur in practice + // because act and agent hash different payloads, producing distinct SHA-256 keys, + // but the `agent-` prefix provides an additional layer of separation on disk. + "agent", ); if (error && path) { this.logger({ @@ -365,6 +371,7 @@ export class AgentCache { const { error, path } = await this.storage.writeJson( `agent-${context.cacheKey}.json`, entry, + "agent", ); if (error && path) { this.logger({ @@ -415,6 +422,7 @@ export class AgentCache { const { error, path } = await this.storage.writeJson( `agent-${payload.cacheKey}.json`, entry, + "agent", ); if (error && path) { this.logger({ @@ -880,6 +888,7 @@ export class AgentCache { const { error, path } = await this.storage.writeJson( `agent-${context.cacheKey}.json`, updatedEntry, + "agent", ); if (error && path) { this.logger({ diff --git a/packages/core/lib/v3/cache/CacheStorage.ts b/packages/core/lib/v3/cache/CacheStorage.ts index d15b5753e..8a1e63729 100644 --- a/packages/core/lib/v3/cache/CacheStorage.ts +++ b/packages/core/lib/v3/cache/CacheStorage.ts @@ -1,7 +1,11 @@ import fs from "fs"; import path from "path"; import type { Logger } from "../types/public/index.js"; -import { ReadJsonResult, WriteJsonResult } from "../types/private/index.js"; +import { + CacheCategory, + ReadJsonResult, + WriteJsonResult, +} from "../types/private/index.js"; const jsonClone = (value: T): T => { const serialized = JSON.stringify(value); @@ -11,11 +15,76 @@ const jsonClone = (value: T): T => { return JSON.parse(serialized) as T; }; +/** + * Configuration for the Valkey cache backend. + */ +export interface ValkeyCacheOptions { + /** Valkey host address. */ + host: string; + /** Valkey port (default: 6379). */ + port?: number; + /** Enable TLS for the connection. */ + useTls?: boolean; + /** Authentication password (IAM token or static auth token). */ + password?: string; + /** Authentication username (for ACL-enabled instances). */ + username?: string; + /** Default TTL in seconds for cache entries. Omit for no expiry. */ + cacheTtl?: number; + /** Key prefix namespace (default: "stagehand"). */ + keyPrefix?: string; + /** Request timeout in ms (default: 5000). */ + requestTimeout?: number; + /** Max allowed cache value size in bytes (default: 5MB). Writes exceeding this are skipped. */ + maxCacheValueBytes?: number; +} + +/** + * Options shape for ValkeyClientLike.set(), matching GLIDE's expiry API. + */ +interface ValkeySetOptions { + expiry?: { type: "EX" | "PX" | "EXAT" | "PXAT"; count: number }; +} + +/** + * Minimal interface matching the subset of Valkey client methods used by + * CacheStorage. This avoids a hard compile-time dependency on iovalkey + * for users who don't need the Valkey backend. + */ +interface ValkeyClientLike { + get(key: string): Promise; + set( + key: string, + value: string, + options?: ValkeySetOptions, + ): Promise; + del(keys: string[]): Promise; + close(): Promise; +} + +/** + * Minimal type shape for the dynamically imported iovalkey module. + */ +interface IovalkeyModule { + default: new (options: Record) => IovalkeyClient; +} + +interface IovalkeyClient { + connect(): Promise; + get(key: string): Promise; + set(key: string, value: string, ...args: unknown[]): Promise; + del(...keys: string[]): Promise; + quit(): Promise; + disconnect(): void; +} + export class CacheStorage { private constructor( private readonly logger: Logger, private readonly dir?: string, private readonly memoryStore?: Map, + private readonly valkeyClient?: ValkeyClientLike, + private readonly valkeyOptions?: ValkeyCacheOptions, ) {} static create( @@ -49,12 +118,118 @@ export class CacheStorage { return new CacheStorage(logger, undefined, new Map()); } + /** + * Create a CacheStorage backed by Valkey via iovalkey. + * Requires `iovalkey` to be installed as an optional dependency. + * Returns a disabled CacheStorage if the connection fails. + */ + static async createValkey( + options: ValkeyCacheOptions, + logger: Logger, + ): Promise { + try { + const mod = (await import( + /* webpackIgnore: true */ /* @vite-ignore */ "iovalkey" + )) as unknown as IovalkeyModule; + const Valkey = mod.default; + + if (options.username && !options.password) { + throw new Error( + "Valkey cache: username was provided without a password. " + + "Supply both username and password, or omit both.", + ); + } + + // Default TLS on when credentials are present to avoid plaintext transit. + const useTLS = options.useTls ?? !!options.password; + const port = options.port ?? 6379; + + const iovalkeyOpts: Record = { + host: options.host, + port, + ...(options.password ? { password: options.password } : {}), + ...(options.username ? { username: options.username } : {}), + ...(useTLS ? { tls: {} } : {}), + commandTimeout: options.requestTimeout ?? 5000, + maxRetriesPerRequest: 3, + retryStrategy: (times: number): number | null => + times > 5 ? null : Math.min(times * 500, 5000), + connectionName: "stagehand-cache", + lazyConnect: true, + }; + + const rawClient = new Valkey(iovalkeyOpts); + await rawClient.connect(); + + // Adapt iovalkey's API to ValkeyClientLike + const client: ValkeyClientLike = { + get: (key) => rawClient.get(key), + set: (key, value, setOpts?) => { + if (setOpts?.expiry) { + return rawClient.set( + key, + value, + setOpts.expiry.type, + setOpts.expiry.count, + ); + } + return rawClient.set(key, value); + }, + del: (keys) => + keys.length > 0 ? rawClient.del(...keys) : Promise.resolve(0), + close: (): Promise => rawClient.quit().then((): void => {}), + }; + + logger({ + category: "cache", + message: `valkey cache connected to ${options.host}:${port}`, + level: 1, + }); + + return new CacheStorage(logger, undefined, undefined, client, options); + } catch (err) { + const safeMessage = err instanceof Error ? err.message : "unknown error"; + logger({ + category: "cache", + message: `unable to initialize valkey cache: ${safeMessage}`, + level: 1, + auxiliary: { + error: { value: safeMessage, type: "string" }, + }, + }); + return new CacheStorage(logger); + } + } + get directory(): string | undefined { return this.dir; } get enabled(): boolean { - return !!this.dir || !!this.memoryStore; + return !!this.dir || !!this.memoryStore || !!this.valkeyClient; + } + + /** True if this storage is backed by a Valkey client. */ + get isValkey(): boolean { + return !!this.valkeyClient; + } + + /** + * Close the underlying Valkey client connection, if any. + * Safe to call multiple times or when no Valkey client is attached. + */ + async close(): Promise { + if (this.valkeyClient) { + try { + await this.valkeyClient.close(); + } catch (err) { + this.logger({ + category: "cache", + message: `valkey close error (best-effort): ${err instanceof Error ? err.message : "unknown"}`, + level: 2, + }); + } + } } private resolvePath(fileName: string): string | null { @@ -62,7 +237,65 @@ export class CacheStorage { return path.join(this.dir, fileName); } - async readJson(fileName: string): Promise> { + /** + * Derive the Valkey key from a cache fileName and explicit category. + * Strips any redundant category prefix from the fileName (e.g. "agent-") + * since the category is already encoded in the key namespace. + */ + private toValkeyKey(fileName: string, category: CacheCategory): string { + const prefix = this.valkeyOptions?.keyPrefix ?? "stagehand"; + const base = fileName.replace(/\.json$/, "").replace(/^agent-/, ""); + return `${prefix}:${category}:${base}`; + } + + async readJson( + fileName: string, + category: CacheCategory = "act", + ): Promise> { + if (this.valkeyClient) { + const key = this.toValkeyKey(fileName, category); + try { + const raw = await this.valkeyClient.get(key); + if (raw === null) { + return { value: null }; + } + try { + return { value: JSON.parse(raw) as T }; + } catch (parseErr) { + // Corrupt data — delete the poisoned key so subsequent reads don't + // keep failing until TTL expiry. + this.logger({ + category: "cache", + message: `valkey key ${key} contains corrupt JSON; deleting`, + level: 1, + auxiliary: { + error: { value: String(parseErr), type: "string" }, + }, + }); + try { + await this.valkeyClient.del([key]); + } catch (delErr) { + this.logger({ + category: "cache", + message: `valkey del error for corrupt key ${key} (best-effort): ${delErr instanceof Error ? delErr.message : "unknown"}`, + level: 2, + }); + } + return { value: null, error: parseErr, path: key }; + } + } catch (err) { + this.logger({ + category: "cache", + message: `valkey read error for key ${key}`, + level: 1, + auxiliary: { + error: { value: String(err), type: "string" }, + }, + }); + return { value: null, error: err, path: key }; + } + } + if (this.memoryStore) { if (!this.memoryStore.has(fileName)) { return { value: null }; @@ -88,7 +321,49 @@ export class CacheStorage { } } - async writeJson(fileName: string, data: unknown): Promise { + async writeJson( + fileName: string, + data: unknown, + category: CacheCategory = "act", + ): Promise { + if (this.valkeyClient) { + const key = this.toValkeyKey(fileName, category); + try { + const serialized = JSON.stringify(data); + const maxBytes = this.valkeyOptions?.maxCacheValueBytes ?? 5_242_880; + if (Buffer.byteLength(serialized, "utf8") > maxBytes) { + this.logger({ + category: "cache", + message: `valkey write skipped: payload exceeds ${maxBytes} byte limit`, + level: 1, + }); + return { + error: new Error("cache value exceeds size limit"), + path: key, + }; + } + const ttl = this.valkeyOptions?.cacheTtl; + if (ttl !== undefined && ttl > 0) { + await this.valkeyClient.set(key, serialized, { + expiry: { type: "EX", count: ttl }, + }); + } else { + await this.valkeyClient.set(key, serialized); + } + return {}; + } catch (err) { + this.logger({ + category: "cache", + message: `valkey write error for key ${key}`, + level: 1, + auxiliary: { + error: { value: String(err), type: "string" }, + }, + }); + return { error: err, path: key }; + } + } + if (this.memoryStore) { this.memoryStore.set(fileName, jsonClone(data)); return {}; diff --git a/packages/core/lib/v3/index.ts b/packages/core/lib/v3/index.ts index 12e04c725..914caf443 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -1,5 +1,5 @@ import * as PublicApi from "./types/public/index.js"; -import { V3 } from "./v3.js"; +import { V3, mapV3OptsToValkeyConfig } from "./v3.js"; import { AnnotatedScreenshotText, LLMClient } from "./llm/LLMClient.js"; import { AgentProvider, @@ -37,6 +37,7 @@ import { export { V3 } from "./v3.js"; export { V3 as Stagehand } from "./v3.js"; +export { mapV3OptsToValkeyConfig } from "./v3.js"; export * from "./types/public/index.js"; export { AnnotatedScreenshotText, LLMClient } from "./llm/LLMClient.js"; @@ -119,6 +120,7 @@ export { getAISDKLanguageModel } from "./llm/LLMProvider.js"; export { __internalCreateInMemoryAgentCacheHandle } from "./cache/serverAgentCache.js"; export { maybeRunShutdownSupervisorFromArgv as __internalMaybeRunShutdownSupervisorFromArgv } from "./shutdown/supervisor.js"; export type { ServerAgentCacheHandle } from "./cache/serverAgentCache.js"; +export type { ValkeyCacheOptions } from "./cache/CacheStorage.js"; export type { ChatMessage, @@ -145,6 +147,7 @@ const StagehandDefault = { ...PublicApi, V3, Stagehand: V3, + mapV3OptsToValkeyConfig, AnnotatedScreenshotText, LLMClient, AgentProvider, diff --git a/packages/core/lib/v3/types/private/cache.ts b/packages/core/lib/v3/types/private/cache.ts index 074f4e59b..85bbea75f 100644 --- a/packages/core/lib/v3/types/private/cache.ts +++ b/packages/core/lib/v3/types/private/cache.ts @@ -73,6 +73,12 @@ export type WriteJsonResult = { error?: unknown; }; +/** + * Discriminator for the Valkey key namespace. + * Passed explicitly to readJson/writeJson to avoid fragile filename parsing. + */ +export type CacheCategory = "act" | "agent"; + export interface CachedActEntry { version: 1; instruction: string; diff --git a/packages/core/lib/v3/types/public/api.ts b/packages/core/lib/v3/types/public/api.ts index b9839f4d1..10703106d 100644 --- a/packages/core/lib/v3/types/public/api.ts +++ b/packages/core/lib/v3/types/public/api.ts @@ -550,6 +550,67 @@ export const SessionStartRequestSchema = z actTimeoutMs: z.number().optional().meta({ description: "Timeout in ms for act operations (deprecated, v2 only)", }), + valkeyCache: z + .object({ + valkeyHost: z.string().meta({ description: "Valkey host address" }), + valkeyPort: z + .number() + .int() + .min(1) + .max(65535) + .optional() + .meta({ description: "Valkey port (default: 6379)" }), + valkeyTls: z + .boolean() + .optional() + .meta({ description: "Enable TLS for the connection" }), + valkeyPassword: z + .string() + .optional() + .meta({ description: "Authentication password" }), + valkeyUsername: z + .string() + .optional() + .meta({ description: "Authentication username" }), + cacheTtl: z + .number() + .int() + .positive() + .optional() + .meta({ description: "TTL in seconds for cache entries" }), + valkeyKeyPrefix: z + .string() + .optional() + .meta({ description: 'Key prefix namespace (default: "stagehand")' }), + // 2_147_483_647 = 2^31 - 1, the max delay Node's setTimeout accepts; + // iovalkey passes commandTimeout straight to setTimeout, and larger + // values wrap to 0 (fire immediately), timing out every command. + valkeyRequestTimeout: z + .number() + .int() + .positive() + .max(2_147_483_647) + .optional() + .meta({ + description: + "Request timeout in ms for Valkey operations (default: 5000)", + }), + // 536_870_912 = 512 MiB, Valkey's default proto-max-bulk-len. Values + // larger than this are rejected by the server, so guarding above it is + // pointless. + valkeyMaxCacheValueBytes: z + .number() + .int() + .positive() + .max(536_870_912) + .optional() + .meta({ + description: + "Max allowed cache value size in bytes (default: 5MB, max: 512MB). Writes exceeding this are skipped.", + }), + }) + .optional() + .meta({ description: "Valkey cache backend configuration" }), }) .meta({ id: "SessionStartRequest" }); diff --git a/packages/core/lib/v3/types/public/options.ts b/packages/core/lib/v3/types/public/options.ts index 1307bb689..1d147595e 100644 --- a/packages/core/lib/v3/types/public/options.ts +++ b/packages/core/lib/v3/types/public/options.ts @@ -59,6 +59,27 @@ export interface V3Options { logger?: (line: LogLine) => void; /** Directory used to persist cached actions for act(). */ cacheDir?: string; + /** + * Valkey host address. When set, uses Valkey as the cache backend. + * Takes precedence over `cacheDir` if both are provided. + */ + valkeyHost?: string; + /** Valkey port (default: 6379). */ + valkeyPort?: number; + /** Enable TLS for the Valkey connection. */ + valkeyTls?: boolean; + /** Valkey authentication password (IAM token or static auth token). */ + valkeyPassword?: string; + /** Valkey authentication username (for ACL-enabled instances). */ + valkeyUsername?: string; + /** TTL in seconds for cache entries stored in Valkey. Omit for no expiry. */ + cacheTtl?: number; + /** Key prefix namespace for Valkey keys (default: "stagehand"). */ + valkeyKeyPrefix?: string; + /** Request timeout in ms for Valkey operations (default: 5000). */ + valkeyRequestTimeout?: number; + /** Max allowed cache value size in bytes for Valkey writes (default: 5MB). Writes exceeding this are skipped. */ + valkeyMaxCacheValueBytes?: number; domSettleTimeout?: number; disableAPI?: boolean; /** diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index d41eb16e9..cb0c3efc5 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -19,7 +19,7 @@ import { extractModelName } from "../modelUtils.js"; import { StagehandLogger, LoggerOptions } from "../logger.js"; import { ActCache } from "./cache/ActCache.js"; import { AgentCache } from "./cache/AgentCache.js"; -import { CacheStorage } from "./cache/CacheStorage.js"; +import { CacheStorage, ValkeyCacheOptions } from "./cache/CacheStorage.js"; import { ActHandler } from "./handlers/actHandler.js"; import { ExtractHandler } from "./handlers/extractHandler.js"; import { ObserveHandler } from "./handlers/observeHandler.js"; @@ -98,6 +98,25 @@ import { EventStore } from "./flowlogger/EventStore.js"; import { createTimeoutGuard } from "./handlers/handlerUtils/timeoutGuard.js"; import { ActTimeoutError } from "./types/public/sdkErrors.js"; +/** + * Centralized mapper: V3Options flat fields -> ValkeyCacheOptions. + * Keeps field-name translation in one place so adding/renaming fields + * only requires a change here. + */ +export function mapV3OptsToValkeyConfig(opts: V3Options): ValkeyCacheOptions { + return { + host: opts.valkeyHost!, + port: opts.valkeyPort, + useTls: opts.valkeyTls, + password: opts.valkeyPassword, + username: opts.valkeyUsername, + cacheTtl: opts.cacheTtl, + keyPrefix: opts.valkeyKeyPrefix, + requestTimeout: opts.valkeyRequestTimeout, + maxCacheValueBytes: opts.valkeyMaxCacheValueBytes, + }; +} + const DEFAULT_MODEL_NAME = "openai/gpt-4.1-mini"; const DEFAULT_VIEWPORT = { width: 1288, height: 711 }; const DEFAULT_AGENT_TOOL_TIMEOUT_MS = 45000; @@ -391,30 +410,14 @@ export class V3 { ); } - this.cacheStorage = CacheStorage.create(opts.cacheDir, this.logger, { - label: "cache directory", - }); - this.actCache = new ActCache({ - storage: this.cacheStorage, - logger: this.logger, - getActHandler: () => this.actHandler, - getDefaultLlmClient: () => this.resolveLlmClient(), - domSettleTimeoutMs: this.domSettleTimeoutMs, - }); - this.agentCache = new AgentCache({ - storage: this.cacheStorage, - logger: this.logger, - getActHandler: () => this.actHandler, - getContext: () => this.ctx, - getDefaultLlmClient: () => this.resolveLlmClient(), - getBaseModelName: () => this.modelName, - getSystemPrompt: () => opts.systemPrompt, - domSettleTimeoutMs: this.domSettleTimeoutMs, - act: this.act.bind(this), - }); - this.opts = opts; + this.wireCaches( + CacheStorage.create(opts.cacheDir, this.logger, { + label: "cache directory", + }), + ); + // FlowLogger always gets a per-instance session context and shared event // bus. The attached EventStore decides which sinks are active: // `BROWSERBASE_FLOW_LOGS=1` enables pretty stderr output, @@ -835,6 +838,33 @@ export class V3 { this.shutdownSupervisor = null; } + /** + * Point cacheStorage, actCache, and agentCache at the given storage backend. + * Used both at construction (file/memory) and during init() when upgrading to + * (or falling back from) the Valkey backend. + */ + private wireCaches(storage: CacheStorage): void { + this.cacheStorage = storage; + this.actCache = new ActCache({ + storage: this.cacheStorage, + logger: this.logger, + getActHandler: () => this.actHandler, + getDefaultLlmClient: () => this.resolveLlmClient(), + domSettleTimeoutMs: this.domSettleTimeoutMs, + }); + this.agentCache = new AgentCache({ + storage: this.cacheStorage, + logger: this.logger, + getActHandler: () => this.actHandler, + getContext: () => this.ctx, + getDefaultLlmClient: () => this.resolveLlmClient(), + getBaseModelName: () => this.modelName, + getSystemPrompt: () => this.opts.systemPrompt, + domSettleTimeoutMs: this.domSettleTimeoutMs, + act: this.act.bind(this), + }); + } + /** * Entrypoint: initializes handlers, launches Chrome or Browserbase, * and sets up a CDP context. @@ -868,6 +898,39 @@ export class V3 { ), this.domSettleTimeoutMs, ); + + // Upgrade to Valkey cache if configured (async connection). + // Placed after actHandler creation so the closure getActHandler: () => this.actHandler + // is guaranteed to resolve to an initialized handler at cache replay time. + if (this.opts.valkeyHost) { + if (this.opts.cacheDir) { + this.logger({ + category: "cache", + message: + "both valkeyHost and cacheDir are set; valkeyHost takes precedence", + level: 2, + }); + } + const valkeyStorage = await CacheStorage.createValkey( + mapV3OptsToValkeyConfig(this.opts), + this.logger, + ); + if (valkeyStorage.enabled) { + this.wireCaches(valkeyStorage); + } else if (this.cacheStorage.isValkey) { + // A prior init() wired a Valkey backend whose client has since been + // closed by close(). The reconnect failed, so re-establish the + // file/memory fallback instead of leaving the dead Valkey storage + // in place (which still reports enabled and would route to a dead + // client). + this.wireCaches( + CacheStorage.create(this.opts.cacheDir, this.logger, { + label: "cache directory", + }), + ); + } + } + this.extractHandler = new ExtractHandler( this.llmClient, this.modelName, @@ -1614,6 +1677,13 @@ export class V3 { } finally { this.stopShutdownSupervisor(); + // Close Valkey connection if present + try { + await this.cacheStorage.close(); + } catch { + // ignore + } + // Reset internal state this.state = { kind: "UNINITIALIZED" }; this.ctx = null; diff --git a/packages/core/package.json b/packages/core/package.json index 9e55fe0df..14f075cf8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -113,6 +113,7 @@ "@ai-sdk/perplexity": "^2.0.13", "@ai-sdk/togetherai": "^1.0.23", "@ai-sdk/xai": "^2.0.26", + "iovalkey": "^0.2.1", "bufferutil": "^4.0.9", "chrome-launcher": "^1.2.0", "ollama-ai-provider-v2": "^1.5.0" diff --git a/packages/core/tests/unit/cache-valkey.test.ts b/packages/core/tests/unit/cache-valkey.test.ts new file mode 100644 index 000000000..e1cf80c17 --- /dev/null +++ b/packages/core/tests/unit/cache-valkey.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { CacheStorage } from "../../lib/v3/cache/CacheStorage.js"; + +/** + * Unit tests for the Valkey-backed CacheStorage. + * These mock the ValkeyClientLike interface to verify key derivation, + * TTL propagation, graceful error handling, and serialization. + */ + +function createMockClient() { + return { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue("OK"), + del: vi.fn().mockResolvedValue(1), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function createValkeyStorage( + client: ReturnType, + options: { + cacheTtl?: number; + keyPrefix?: string; + maxCacheValueBytes?: number; + } = {}, +): CacheStorage { + // Access private constructor via reflection for testing. + // In production code, createValkey() handles this. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Ctor = CacheStorage as any; + return new Ctor(vi.fn(), undefined, undefined, client, { + host: "localhost", + ...options, + }); +} + +describe("CacheStorage Valkey backend", () => { + let client: ReturnType; + + beforeEach(() => { + client = createMockClient(); + }); + + describe("key derivation", () => { + it("derives act key with explicit category", async () => { + const storage = createValkeyStorage(client); + await storage.readJson("abc123def.json", "act"); + expect(client.get).toHaveBeenCalledWith("stagehand:act:abc123def"); + }); + + it("derives agent key with explicit category", async () => { + const storage = createValkeyStorage(client); + await storage.readJson("xyz789.json", "agent"); + expect(client.get).toHaveBeenCalledWith("stagehand:agent:xyz789"); + }); + + it("strips redundant agent- prefix from filename in Valkey key", async () => { + const storage = createValkeyStorage(client); + await storage.readJson("agent-abc123.json", "agent"); + expect(client.get).toHaveBeenCalledWith("stagehand:agent:abc123"); + }); + + it("defaults to act category when omitted", async () => { + const storage = createValkeyStorage(client); + await storage.readJson("abc.json"); + expect(client.get).toHaveBeenCalledWith("stagehand:act:abc"); + }); + + it("uses custom keyPrefix", async () => { + const storage = createValkeyStorage(client, { + keyPrefix: "myapp", + }); + await storage.readJson("abc.json", "act"); + expect(client.get).toHaveBeenCalledWith("myapp:act:abc"); + }); + }); + + describe("readJson", () => { + it("returns null value on cache miss", async () => { + client.get.mockResolvedValue(null); + const storage = createValkeyStorage(client); + const result = await storage.readJson("missing.json"); + expect(result).toEqual({ value: null }); + }); + + it("deserializes JSON on cache hit", async () => { + const data = { version: 1, instruction: "click button" }; + client.get.mockResolvedValue(JSON.stringify(data)); + const storage = createValkeyStorage(client); + const result = await storage.readJson("hit.json"); + expect(result).toEqual({ value: data }); + }); + + it("returns error on client failure without throwing", async () => { + const err = new Error("connection refused"); + client.get.mockRejectedValue(err); + const storage = createValkeyStorage(client); + const result = await storage.readJson("fail.json"); + expect(result.value).toBeNull(); + expect(result.error).toBe(err); + }); + + it("returns error on corrupt JSON and deletes the key", async () => { + client.get.mockResolvedValue("not valid json {{{"); + const storage = createValkeyStorage(client); + const result = await storage.readJson("corrupt.json"); + expect(result.value).toBeNull(); + expect(result.error).toBeInstanceOf(SyntaxError); + // Should delete the poisoned key + expect(client.del).toHaveBeenCalledWith(["stagehand:act:corrupt"]); + }); + }); + + describe("writeJson", () => { + it("serializes data as JSON string", async () => { + const storage = createValkeyStorage(client); + const data = { version: 1, actions: [{ selector: "button" }] }; + await storage.writeJson("key.json", data); + expect(client.set).toHaveBeenCalledWith( + "stagehand:act:key", + JSON.stringify(data), + ); + }); + + it("applies TTL when cacheTtl is set", async () => { + const storage = createValkeyStorage(client, { cacheTtl: 3600 }); + await storage.writeJson("key.json", { test: true }); + expect(client.set).toHaveBeenCalledWith( + "stagehand:act:key", + JSON.stringify({ test: true }), + { expiry: { type: "EX", count: 3600 } }, + ); + }); + + it("omits TTL options when cacheTtl is not set", async () => { + const storage = createValkeyStorage(client); + await storage.writeJson("key.json", { test: true }); + expect(client.set).toHaveBeenCalledWith( + "stagehand:act:key", + JSON.stringify({ test: true }), + ); + }); + + it("returns error on client failure without throwing", async () => { + const err = new Error("write timeout"); + client.set.mockRejectedValue(err); + const storage = createValkeyStorage(client); + const result = await storage.writeJson("fail.json", {}); + expect(result.error).toBe(err); + }); + }); + + describe("enabled", () => { + it("reports enabled when valkey client is attached", () => { + const storage = createValkeyStorage(client); + expect(storage.enabled).toBe(true); + }); + }); + + describe("isValkey", () => { + it("reports true when valkey client is attached", () => { + const storage = createValkeyStorage(client); + expect(storage.isValkey).toBe(true); + }); + + it("reports false for a disabled (no-op) storage", () => { + const storage = CacheStorage.create(undefined, vi.fn()); + expect(storage.isValkey).toBe(false); + expect(storage.enabled).toBe(false); + }); + + it("reports false for a memory-backed storage", () => { + const storage = CacheStorage.createMemory(vi.fn()); + expect(storage.isValkey).toBe(false); + }); + }); + + describe("close", () => { + it("closes the valkey client", async () => { + const storage = createValkeyStorage(client); + await storage.close(); + expect(client.close).toHaveBeenCalled(); + }); + + it("does not throw if close fails", async () => { + client.close.mockRejectedValue(new Error("already closed")); + const storage = createValkeyStorage(client); + await expect(storage.close()).resolves.toBeUndefined(); + }); + }); + + describe("createValkey factory (connection failure)", () => { + it("returns disabled storage when connection fails", async () => { + const logger = vi.fn(); + + // Mock the dynamic import so this test is hermetic — no native binary + // or network required. + vi.doMock("iovalkey", () => ({ + default: class MockValkey { + connect() { + return Promise.reject(new Error("connect ECONNREFUSED")); + } + }, + })); + + const storage = await CacheStorage.createValkey( + { host: "nonexistent-host-that-cannot-connect", requestTimeout: 500 }, + logger, + ); + expect(storage.enabled).toBe(false); + // Should have logged a warning + expect(logger).toHaveBeenCalledWith( + expect.objectContaining({ + category: "cache", + message: expect.stringContaining("unable to initialize valkey cache"), + }), + ); + + vi.doUnmock("iovalkey"); + }); + + it("re-init fallback: replaces closed Valkey storage with file/memory when reconnect fails", async () => { + const logger = vi.fn(); + + // Simulate the state after a successful init() + close(): + // cacheStorage is a Valkey-backed storage whose client has been closed. + const closedValkeyStorage = createValkeyStorage(createMockClient()); + expect(closedValkeyStorage.isValkey).toBe(true); + expect(closedValkeyStorage.enabled).toBe(true); + + // On the next init(), createValkey is called but fails. + vi.doMock("iovalkey", () => ({ + default: class MockValkey { + connect() { + return Promise.reject(new Error("connect ECONNREFUSED")); + } + }, + })); + + const reconnectAttempt = await CacheStorage.createValkey( + { host: "unreachable-valkey-host", requestTimeout: 500 }, + logger, + ); + + // Replicate the guard in v3.init(): + // if (valkeyStorage.enabled) { wireCaches(valkeyStorage) } + // else if (this.cacheStorage.isValkey) { wireCaches(CacheStorage.create(...)) } + let activeStorage = closedValkeyStorage; + if (reconnectAttempt.enabled) { + activeStorage = reconnectAttempt; + } else if (closedValkeyStorage.isValkey) { + activeStorage = CacheStorage.create(undefined, logger); + } + + // The dead Valkey storage must be replaced with a safe no-op storage. + expect(activeStorage).not.toBe(closedValkeyStorage); + expect(activeStorage.isValkey).toBe(false); + + vi.doUnmock("iovalkey"); + }); + + it("does not clobber existing file-backed storage on connection failure (valkeyHost precedence)", async () => { + const logger = vi.fn(); + + // Simulate the v3.init() precedence logic: + // 1. A file-backed storage exists (cacheDir was set) + const fileStorage = CacheStorage.create( + "/tmp/stagehand-test-cache", + logger, + ); + expect(fileStorage.enabled).toBe(true); + + // 2. valkeyHost is also set, so we attempt createValkey + vi.doMock("iovalkey", () => ({ + default: class MockValkey { + connect() { + return Promise.reject(new Error("connect ECONNREFUSED")); + } + }, + })); + + const valkeyStorage = await CacheStorage.createValkey( + { host: "unreachable-valkey-host", requestTimeout: 500 }, + logger, + ); + + // 3. The guard: only replace if Valkey connected successfully + // This replicates the `if (valkeyStorage.enabled)` check in v3.init() + let activeStorage = fileStorage; + if (valkeyStorage.enabled) { + activeStorage = valkeyStorage; + } + + // The file-backed storage must survive — Valkey failure must NOT + // replace a working cache with a disabled one. + expect(activeStorage).toBe(fileStorage); + expect(activeStorage.enabled).toBe(true); + expect(activeStorage.directory).toBe("/tmp/stagehand-test-cache"); + + vi.doUnmock("iovalkey"); + }); + }); + + describe("size-limit enforcement", () => { + it("skips write and returns error when payload exceeds maxCacheValueBytes", async () => { + const storage = createValkeyStorage(client, { + maxCacheValueBytes: 50, + }); + // Create a payload whose JSON serialization exceeds 50 bytes + const largeData = { content: "a".repeat(100) }; + const result = await storage.writeJson("big.json", largeData); + + expect(result.error).toBeInstanceOf(Error); + expect((result.error as Error).message).toBe( + "cache value exceeds size limit", + ); + // Should include the key as path so callers' guards fire + expect(result.path).toBe("stagehand:act:big"); + // set() must NOT have been called + expect(client.set).not.toHaveBeenCalled(); + }); + + it("allows write when payload is within maxCacheValueBytes", async () => { + const storage = createValkeyStorage(client, { + maxCacheValueBytes: 5_000, + }); + const smallData = { ok: true }; + const result = await storage.writeJson("small.json", smallData); + + expect(result.error).toBeUndefined(); + expect(client.set).toHaveBeenCalled(); + }); + + it("measures byte length not character length for multi-byte content", async () => { + // Each emoji is 4 bytes in UTF-8 but 2 UTF-16 code units (.length = 2) + const emoji = "😀"; + const payload = { text: emoji.repeat(10) }; + const serialized = JSON.stringify(payload); + const charLength = serialized.length; + const byteLength = Buffer.byteLength(serialized, "utf8"); + + // Set limit between char length and byte length to verify byte measurement + const storage = createValkeyStorage(client, { + maxCacheValueBytes: charLength + 1, + }); + + if (byteLength > charLength + 1) { + // The byte length exceeds our limit even though char length doesn't + const result = await storage.writeJson("emoji.json", payload); + expect(result.error).toBeInstanceOf(Error); + expect(client.set).not.toHaveBeenCalled(); + } else { + // If somehow they're equal, the write should succeed + const result = await storage.writeJson("emoji.json", payload); + expect(result.error).toBeUndefined(); + } + }); + }); + + describe("write error includes path for caller guards", () => { + it("returns valkey key as path on connection error", async () => { + const err = new Error("write timeout"); + client.set.mockRejectedValue(err); + const storage = createValkeyStorage(client); + const result = await storage.writeJson("fail.json", { x: 1 }); + expect(result.error).toBe(err); + expect(result.path).toBe("stagehand:act:fail"); + }); + + it("returns valkey key as path on read error", async () => { + const err = new Error("connection reset"); + client.get.mockRejectedValue(err); + const storage = createValkeyStorage(client); + const result = await storage.readJson("broken.json", "agent"); + expect(result.error).toBe(err); + expect(result.path).toBe("stagehand:agent:broken"); + }); + }); +}); diff --git a/packages/server-v3/openapi.v3.yaml b/packages/server-v3/openapi.v3.yaml index bdbf78aa4..3430ea1ef 100644 --- a/packages/server-v3/openapi.v3.yaml +++ b/packages/server-v3/openapi.v3.yaml @@ -761,6 +761,48 @@ components: actTimeoutMs: description: Timeout in ms for act operations (deprecated, v2 only) type: number + valkeyCache: + description: Valkey cache backend configuration + type: object + properties: + valkeyHost: + description: Valkey host address + type: string + valkeyPort: + description: "Valkey port (default: 6379)" + type: integer + minimum: 1 + maximum: 65535 + valkeyTls: + description: Enable TLS for the connection + type: boolean + valkeyPassword: + description: Authentication password + type: string + valkeyUsername: + description: Authentication username + type: string + cacheTtl: + description: TTL in seconds for cache entries + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + valkeyKeyPrefix: + description: 'Key prefix namespace (default: "stagehand")' + type: string + valkeyRequestTimeout: + description: "Request timeout in ms for Valkey operations (default: 5000)" + type: integer + exclusiveMinimum: 0 + maximum: 2147483647 + valkeyMaxCacheValueBytes: + description: "Max allowed cache value size in bytes (default: 5MB, max: 512MB). + Writes exceeding this are skipped." + type: integer + exclusiveMinimum: 0 + maximum: 536870912 + required: + - valkeyHost required: - modelName SessionStartResult: diff --git a/packages/server-v3/src/lib/InMemorySessionStore.ts b/packages/server-v3/src/lib/InMemorySessionStore.ts index 67fa0d5e0..4e9994104 100644 --- a/packages/server-v3/src/lib/InMemorySessionStore.ts +++ b/packages/server-v3/src/lib/InMemorySessionStore.ts @@ -12,6 +12,19 @@ import type { const DEFAULT_MAX_CAPACITY = 100; const DEFAULT_TTL_MS = 0; // 0 = infinite (no TTL-based eviction) +function parseIntEnv(name: string): number | undefined { + const raw = process.env[name]; + if (!raw) return undefined; + const n = parseInt(raw, 10); + if (Number.isNaN(n)) { + // Don't hard-fail the session request on a misconfigured env var; warn and + // fall back to the default (undefined) so caching degrades gracefully. + console.warn(`Env var ${name} must be numeric, got: "${raw}"; ignoring.`); + return undefined; + } + return n; +} + /** * Internal node for LRU linked list */ @@ -246,6 +259,27 @@ export class InMemorySessionStore implements SessionStore { }, }; + if (params.valkeyCache) { + Object.assign(options, params.valkeyCache); + } else if (process.env.VALKEY_HOST) { + options.valkeyHost = process.env.VALKEY_HOST; + options.valkeyPort = parseIntEnv("VALKEY_PORT"); + options.valkeyTls = + process.env.VALKEY_TLS === "true" + ? true + : process.env.VALKEY_TLS === "false" + ? false + : undefined; + options.valkeyPassword = process.env.VALKEY_PASSWORD || undefined; + options.valkeyUsername = process.env.VALKEY_USERNAME || undefined; + options.cacheTtl = parseIntEnv("VALKEY_CACHE_TTL"); + options.valkeyKeyPrefix = process.env.VALKEY_KEY_PREFIX || undefined; + options.valkeyRequestTimeout = parseIntEnv("VALKEY_REQUEST_TIMEOUT"); + options.valkeyMaxCacheValueBytes = parseIntEnv( + "VALKEY_MAX_CACHE_VALUE_BYTES", + ); + } + if (isBrowserbase) { options.apiKey = params.browserbaseApiKey; options.projectId = params.browserbaseProjectId; diff --git a/packages/server-v3/src/lib/SessionStore.ts b/packages/server-v3/src/lib/SessionStore.ts index 24aa8f2a4..b3717a159 100644 --- a/packages/server-v3/src/lib/SessionStore.ts +++ b/packages/server-v3/src/lib/SessionStore.ts @@ -58,6 +58,18 @@ export interface CreateSessionParams { clientLanguage?: string; /** SDK version */ sdkVersion?: string; + /** Valkey cache backend configuration */ + valkeyCache?: { + valkeyHost: string; + valkeyPort?: number; + valkeyTls?: boolean; + valkeyPassword?: string; + valkeyUsername?: string; + cacheTtl?: number; + valkeyKeyPrefix?: string; + valkeyRequestTimeout?: number; + valkeyMaxCacheValueBytes?: number; + }; } /** diff --git a/packages/server-v3/src/routes/v1/sessions/start.ts b/packages/server-v3/src/routes/v1/sessions/start.ts index b36d9f83f..b5386bc66 100644 --- a/packages/server-v3/src/routes/v1/sessions/start.ts +++ b/packages/server-v3/src/routes/v1/sessions/start.ts @@ -220,6 +220,7 @@ const startRouteHandler: RouteHandler = withErrorHandling( clientLanguage, sdkVersion, experimental, + valkeyCache: body.valkeyCache, localBrowserLaunchOptions: browserType === "local" && (browser?.launchOptions || browser?.cdpUrl) ? { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 535721b73..cba51dbbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,10 @@ overrides: importers: .: + dependencies: + iovalkey: + specifier: ^0.2.1 + version: 0.2.2 devDependencies: '@changesets/changelog-github': specifier: ^0.5.0 @@ -61,6 +65,9 @@ importers: lint-staged: specifier: ^16.4.0 version: 16.4.0 + long: + specifier: ^5.3.2 + version: 5.3.2 prettier: specifier: ^3.2.5 version: 3.5.3 @@ -258,6 +265,9 @@ importers: chrome-launcher: specifier: ^1.2.0 version: 1.2.0 + iovalkey: + specifier: ^0.2.1 + version: 0.2.2 ollama-ai-provider-v2: specifier: ^1.5.0 version: 1.5.0(zod@4.2.1) @@ -1735,6 +1745,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -3421,6 +3434,10 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + code-excerpt@4.0.0: resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3691,6 +3708,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4777,6 +4798,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iovalkey@0.2.2: + resolution: {integrity: sha512-7eVmLOYV2UamZ/YPXuUwTu/4zBDxXcfjj/wmOwlKBBhU2qjg60Th0Y/cqfED3OxNAhc6hUV2Ft4eQMCKi2EMpQ==} + engines: {node: '>=18.12.0'} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -5179,6 +5204,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -5192,6 +5223,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -6254,6 +6288,14 @@ packages: recma-stringify@1.0.0: resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -6700,6 +6742,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -9114,6 +9159,8 @@ snapshots: optionalDependencies: '@types/node': 25.6.2 + '@ioredis/commands@1.10.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -10638,7 +10685,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.17.32)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@20.17.32)(jiti@2.6.1)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.17.32)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@20.17.32)(jiti@2.6.1)(tsx@4.19.4)(yaml@2.9.0)) '@vitest/expect@4.1.9': dependencies: @@ -11314,6 +11361,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.0 + cluster-key-slot@1.1.2: {} + code-excerpt@4.0.0: dependencies: convert-to-spaces: 2.0.1 @@ -11559,6 +11608,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} dependency-graph@0.11.0: {} @@ -13141,6 +13192,20 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + iovalkey@0.2.2: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3(supports-color@8.1.1) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.2.0: {} ip-regex@4.3.0: {} @@ -13563,6 +13628,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.startcase@4.4.0: {} lodash.topath@4.5.2: {} @@ -13577,6 +13646,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.0 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -15059,6 +15130,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -15775,6 +15852,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} std-env@4.1.0: {}