@cactus-agents/platform-cache
Engine de cache multi-camada para Cloudflare Workers. Abstrai memória + CF Cache API + KV em uma interface única orientada a recursos, com política configurável via variável de ambiente.
Instalação
pnpm add @cactus-agents/platform-cache
Visão geral
O platform-cache implementa um pipeline de cache em camadas:
Requisição
↓
1. Memory (in-process, instantâneo)
↓ miss
2. CF Cache API (edge, compartilhado entre workers)
↓ miss
3. Origin (fetch real)
↓ erro de origem
4. KV Snapshot (fallback estável — se habilitado na policy)
A política é definida em front-ops (YAML por brand/env) e injetada em deploy-time como PLATFORM_CACHE_POLICY_JSON. Se a variável estiver ausente, o engine trabalha em modo bypass — toda requisição vai direto à origem, sem cache (ideal para dev local).
Uso básico
import {
createCacheEngine,
parseCachePolicy,
CacheApiStore,
KvSnapshotStore,
NoopSnapshotStore,
} from "@cactus-agents/platform-cache";
// Em um Cloudflare Worker loader:
const policy = parseCachePolicy(env.PLATFORM_CACHE_POLICY_JSON);
const engine = createCacheEngine({
policy,
primaryStore: new CacheApiStore(env.CACHE_GENERATION),
snapshotStore: env.PLATFORM_CACHE_KV
? new KvSnapshotStore(env.PLATFORM_CACHE_KV)
: new NoopSnapshotStore(),
domain: env.ORIGIN_DOMAIN,
});
// Buscar um recurso com cache:
const result = await engine.fetch(
"brandConfig",
() => createBrandFromClient(client, opts),
{ waitUntil: ctx.waitUntil }
);
if (result.data) {
// result.status: "hit" | "stale" | "miss" | "fallback" | "error"
// result.source: "memory" | "cache_api" | "kv" | "origin" | null
}
API
createCacheEngine(config)
Cria uma instância do engine. Aceita CacheEngineConfig:
interface CacheEngineConfig {
policy: CachePolicy | null; // null = bypass (dev local sem policy)
primaryStore: PrimaryStore; // CacheApiStore ou implementação custom
snapshotStore: SnapshotStore; // KvSnapshotStore ou NoopSnapshotStore
domain: string; // ORIGIN_DOMAIN (ex: "casateste.com")
}
Retorna CacheEngine:
| Método | Descrição |
|---|---|
fetch(resource, fetcher, opts?) | Busca via pipeline — usa a chave padrão do resource |
fetchWithKey(resource, suffix, fetcher, opts?) | Busca com chave dinâmica — útil para recursos com múltiplas entradas (ex: detalhe de jogo por slug) |
purge(resource, suffix?) | Remove um resource específico do primary cache |
purgeByTag(tag) | Remove todos os resources que declaram essa tag na policy. Retorna a lista de resources purgados. |
purgeByTagGlob(pattern) | Mesmo que purgeByTag, mas o tag é interpretado como glob (* casa qualquer sequência, ? casa um char). Ex: purgeByTagGlob("games:*") |
purgeAll() | Remove todos os entries deste domínio do memory tier (e KV snapshots de resources com fallbackEnabled) |
Rail de segurança: recursos por usuário
fetch() valida o nome do resource contra FORBIDDEN_RESOURCE_PATTERNS e joga um erro quando ele parece ser dado por usuário. O cache é global, não por usuário — uma única chamada errada popula o cache compartilhado com os dados de um jogador, que todos os seguintes recebem. O guard existe por causa de um postmortem de vazamento de saldo (2026-04-29).
Prefixos rejeitados (case-insensitive, ancorados no início do nome): wallet, balance, userProfile, userInfo, profile, account (exceto accountConfig/accountsConfig), transactions, kyc, auth, rewards, gamification, bets, history, referral, deposit, withdraw, income, bonus, user.
Para cachear algo por usuário, use fetchWithKey(resource, userId, ...) com o id do usuário no suffix. fetchWithKey não aplica esse guard de nome (é o caminho seguro por design), mas rejeita suffix vazio — um suffix vazio colapsaria todos os usuários na mesma entrada, que é exatamente o bug que se quer evitar.
Se um nome legítimo bater num padrão (walletConfig para config global, por exemplo), a lista em packages/platform-cache/src/engine.ts precisa ser atualizada — não contorne o guard no call-site.
Single-flight (coalescing)
O engine colapsa N callers paralelos pela mesma chave em uma única execução do pipeline: o primeiro dispara, os outros aguardam a mesma promise. Sem isso, quando uma chave quente expira e N requests chegam ao mesmo datacenter no mesmo instante, todas as N fazem primary miss → kv miss → origin fetch em paralelo (thundering herd no BFF).
- A chave de coalescing é
${cacheKey}:${mode}, então leiturascache-onlynão compartilham entrada com leiturasnormal. - O escopo é por instância do engine = por isolate. Coalescing entre isolates exigiria camada de coordenação (KV/Durable Object).
- Há um timeout de segurança de 10s: se um pipeline nunca assentar, a entrada é despejada para que novos callers possam tentar de novo.
FetchOptions
interface FetchOptions {
mode?: "normal" | "cache-only"; // default: "normal"
waitUntil?: (promise: Promise<unknown>) => void; // ctx.waitUntil do CF
}
"normal"— pipeline completo: memory → cache_api → origin → snapshot"cache-only"— consulta apenas o cache primário, nunca chama a origem. Retornanullem miss.
O waitUntil é essencial para stale-while-revalidate: ao passar ctx.waitUntil, a revalidação em background é registrada no Worker sem bloquear a resposta.
parseCachePolicy(json)
Deserializa a policy do env var PLATFORM_CACHE_POLICY_JSON:
const policy = parseCachePolicy(env.PLATFORM_CACHE_POLICY_JSON);
// Retorna CachePolicy | null
// null quando a string está ausente, vazia ou inválida
:::caution É validação de shape, não de valor
parseCachePolicy faz JSON.parse, descarta as entradas cujo shape não bate com ResourcePolicy (enabled/ttlSeconds/staleWhileRevalidateSeconds/staleIfErrorSeconds/fallbackEnabled + storage com primary: "cache_api", snapshot: "kv" | "none" e keyPrefix) e devolve os valores verbatim. Não há clamp, nem Math.min, nem teto de TTL em lugar nenhum do pacote.
Ou seja: um ttlSeconds de 7 dias no YAML passa direto e vale 7 dias em produção. Se você está diagnosticando cache stale, não presuma um limite que o código não aplica.
Se o resto do JSON for válido mas nenhuma entrada passar a validação, o retorno é null — o que coloca o engine em bypass, não em "policy parcial".
:::
buildCacheKey(domain, keyPrefix, suffix?)
Gera a chave de cache no formato esperado pela CF Cache API:
https://{domain}-cache/{keyPrefix}:{suffix}/v1
Normalmente não é necessário chamar diretamente — o engine resolve as chaves internamente via ResourcePolicy.storage.keyPrefix.
getResourcePolicy(policy, resource)
Retorna a ResourcePolicy para um recurso específico, ou null se a policy é nula, o recurso não existe ou está desabilitado:
const rp = getResourcePolicy(policy, "brandConfig");
// null = recurso não configurado ou desabilitado → bypass
findResourcesByTag(policy, tag)
Retorna todos os resource names cuja policy declara o tag (match exato):
findResourcesByTag(policy, "catalog")
// → ["homeRows", "casinoRows", "casinoLiveRows", "gamesBase", ...]
findResourcesByTagGlob(policy, pattern)
Como findResourcesByTag, mas o pattern aceita glob (* casa qualquer sequência, ? casa um char):
findResourcesByTagGlob(policy, "games:*")
// → resources com tags games:list, games:detail, games:wins, etc
findResourcesByTagGlob(policy, "*")
// → todos os resources tageados (escape hatch pra "tudo")
listAllTags(policy)
Retorna todas as tags declaradas na policy, ordenadas alfabeticamente. Útil pra endpoints de introspecção:
listAllTags(policy)
// → ["appearance", "brand", "casino", "catalog", "config", "games:base", ...]
Stores
CacheApiStore
Store primária (memory in-process + CF Cache API). Dois níveis de cache em uma store só:
- Memory: Map em memória, resolvido instantaneamente
- CF Cache API: Cache de edge compartilhado entre instâncias do Worker
class CacheApiStore {
constructor(cacheGeneration?: string, logger?: CacheStoreLogger);
}
const store = new CacheApiStore(env.CACHE_GENERATION, log);
cacheGeneration— sufixo do namespace da CF Cache API:caches.open("platform-cache-{generation}"). Isola as entradas do platform-cache do cache de resposta SSR (que usacaches.default) e é a alavanca de clean slate do operador (ver abaixo). Quando omitido/vazio, cai emDEFAULT_CACHE_GENERATION.logger— opcional,CacheStoreLogger(warnobrigatório,infoopcional).
Em dev local (fora do Worker), a CF Cache API não está disponível — o store opera apenas em memória.
O tier de CF Cache API recalcula a idade real da entrada a partir do header X-Cache-Created-At, então o TTL da policy é respeitado e o SWR realmente dispara — sem isso todo hit de CF reiniciaria o relógio.
Cache generation — duas camadas, e qual manda
import { DEFAULT_CACHE_GENERATION } from "@cactus-agents/platform-cache";
| Camada | Onde | Papel |
|---|---|---|
| Autoritativa | front-ops/config/cache/generation.yml → current | Valor operacional, rotacionado pelo workflow bump-cache-generation e injetado nos workers por env var (CACHE_GENERATION) no deploy |
| Fallback | packages/platform-cache/src/generation.ts → DEFAULT_CACHE_GENERATION | Usado apenas quando nenhum valor é injetado por env |
O token vive num só lugar de propósito: bumpar invalida, de uma vez, o namespace da CF Cache API do worker de brand, o prefixo do LocalStorageCache do api-client e o namespace do front-service-api. Entradas antigas ficam órfãs e são despejadas por LRU — sem purge manual contra datacenters possivelmente inconsistentes.
:::caution Não fixe o número em doc nem em código
O valor é bumpado com frequência (dezenas de vezes em poucos meses). Leia sempre de front-ops/config/cache/generation.yml; qualquer número copiado para outro lugar envelhece em dias. O playbook do operador é o workflow bump-cache-generation no front-ops — não editar o YAML à mão. Ver cache-operations.
:::
KvSnapshotStore
Store de snapshot usando Cloudflare KV. Usado como fallback estável quando a origem está falhando:
import type { KVNamespaceLike } from "@cactus-agents/platform-cache";
const store = new KvSnapshotStore(env.PLATFORM_CACHE_KV);
O binding PLATFORM_CACHE_KV é provisionado automaticamente pelo front-ops em deploy-time. O tipo KVNamespaceLike é exportado para facilitar mocks em testes.
NoopSnapshotStore
Snapshot store noop — não salva nem lê nada. Use quando KV não estiver disponível (dev local, ou quando fallbackEnabled: false para todos os recursos):
const store = new NoopSnapshotStore();
MemoryStore
Store de memória simples para testes unitários. Não usa CF Cache API:
import { MemoryStore } from "@cactus-agents/platform-cache";
// Útil em testes onde CF Cache API não está disponível
Tipos
CacheResult<T>
Retornado por engine.fetch() e engine.fetchWithKey():
interface CacheResult<T> {
data: T | null;
status: CacheResultStatus; // "hit" | "stale" | "miss" | "fallback" | "error"
source: CacheResultSource; // "memory" | "cache_api" | "kv" | "origin" | null
revalidated: boolean; // true = revalidação em background foi disparada
resource: string; // nome do recurso
error?: unknown; // presente quando status === "error"
}
Quando status === "error", use extractApiError() do @cactus-agents/api-client para normalizar o erro.
ResourcePolicy
Política de um recurso específico (vem do YAML do front-ops):
interface ResourcePolicy {
enabled: boolean;
ttlSeconds: number; // tempo de cache "fresh"
staleWhileRevalidateSeconds: number; // extra período para retornar stale + revalidar em BG
staleIfErrorSeconds: number; // extra período para retornar stale se origem errar
fallbackEnabled: boolean; // habilita KV snapshot como last-resort
storage: ResourceStorageConfig;
/**
* Cache tags — usados por `purgeByTag` e `purgeByTagGlob` para
* invalidar grupos de resources sem enumerá-los por nome.
* Convenções:
* - palavra simples: "catalog", "brand", "stats"
* - colon-separated para hierarquias: "games:list", "games:detail"
* - glob aceita no purge: purgeByTagGlob("games:*")
* Default: [] (resource só purgavel por nome).
*/
tags?: string[];
}
CachePolicy
Mapa de nome do recurso → ResourcePolicy:
type CachePolicy = Record<string, ResourcePolicy>;
Outros tipos exportados
| Tipo | Uso |
|---|---|
PrimaryStore / SnapshotStore | Contratos das stores — implemente para trocar o backend |
PrimaryStoreHit<T> | O que uma PrimaryStore.get() devolve: os dados + se ainda estão fresh |
CacheStoreLogger | Logger injetado no CacheApiStore (warn obrigatório, info opcional) |
ResourceStorageConfig | Bloco storage da ResourcePolicy |
KVNamespaceLike | Shape mínimo de KV — facilita mock em teste |
PurgeHandlerOptions / PurgeResult | Assinatura do createPurgeHandler |
Env vars relacionadas
| Variável | Tipo | Descrição |
|---|---|---|
PLATFORM_CACHE_POLICY_JSON | string (JSON) | Política serializada, injetada pelo front-ops em deploy-time. Ausente em dev = bypass. |
PLATFORM_CACHE_KV | KV binding | Binding KV para snapshots. Configurado no wrangler.toml gerado pelo front-ops. |
CACHE_GENERATION | string | Token de geração injetado no deploy a partir de front-ops/config/cache/generation.yml. Vira o sufixo do namespace da CF Cache API. Ausente = DEFAULT_CACHE_GENERATION. |
Veja env-vars para o contexto completo.
Recursos padrão cacheaveis
Os defaults vivem em front-ops/config/cache/defaults.yml — é a fonte de verdade dos nomes de resource, TTLs, SWR, tags e uso de KV. Não replicamos a tabela aqui porque ela muda por PR de ops; leia o YAML.
Precedência de override, do mais fraco para o mais forte:
defaults.yml < config/brands/<brand>/cache.yml < config/brands/<brand>/environments/<env>/cache.yml
Cada nível é full replace do resource, não deep merge.
Grupos de resource, para orientação (nomes exatos no YAML): config de brand (brandConfig), rows de página (homeRows, casinoRows, casinoLiveRows), catálogo (gamesBase, allGames, gameDetail, gameCategory, gameProvider, gameListPage), stats e wins (topWins, lastWins, topGames, gameStats, gameTopWins), conteúdo (content, contentList) e legal (legalTerms).
:::note Sobre o "teto de 1h"
Vários resources trazem o comentário # 1h (capped) no YAML. Isso é convenção de revisão de ops, não uma regra que o pacote aplica — parseCachePolicy não faz clamp (ver acima). TTLs maiores que 3600s passam e valem. Se o teto tiver de ser garantido, o lugar é lint/review no front-ops, não o runtime.
:::
Invalidação de cache
Via purge handler (HTTP)
O package exporta createPurgeHandler que monta uma rota HTTP de purge autenticada:
import { createPurgeHandler } from "@cactus-agents/platform-cache";
// app/routes/api/cache/purge.ts
export async function action({ request, context }) {
const cf = context?.cloudflare?.env;
const engine = createPlatformCacheEngine(cf);
const secret = cf?.CACHE_PURGE_SECRET ?? "";
return createPurgeHandler({ engine, secret }).handle(request);
}
O handler aceita:
// Por tags (preferido)
{ "tags": ["catalog", "brand"] }
// Por glob de tag
{ "glob": "games:*" }
// Por segments (legado — resource names diretamente)
{ "segments": ["homeRows", "casinoRows"] }
// Sem nada = purgeAll
{}
Auth: header X-Cache-Secret deve casar com CACHE_PURGE_SECRET.
Programático
// Por resource name
await engine.purge("brandConfig");
await engine.purge("gameDetail", "sweet-bonanza"); // com suffix
// Por tag
const purged = await engine.purgeByTag("brand");
// → ["brandConfig"]
// Por glob
const purged = await engine.purgeByTagGlob("games:*");
// → ["homeRows", "casinoRows", "gameDetail", ...]
// Tudo (também limpa KV snapshot de resources com fallbackEnabled)
await engine.purgeAll();
A CF Cache API não suporta purge global programático; o purgeByPrefix itera as entries que o CacheApiStore rastreou em activeKeys. Resources nunca tocados nesse isolate não são afetados (não importa — eles também não estão "warm").
Exemplos
Busca com chave dinâmica (game detail)
const result = await engine.fetchWithKey(
"gameDetail",
`${provider}/${game}`, // suffix único por jogo
() => gamesService.getGameDetail(provider, game),
{ waitUntil: ctx.waitUntil }
);
Cache-only (search pré-populado)
// Não chama a origem se não tiver no cache — retorna null em miss
const cached = await engine.fetch(
"gamesBase",
() => { throw new Error("not reached"); },
{ mode: "cache-only" }
);