Pular para o conteúdo principal

@cactus-agents/api-client

HTTP client framework-agnostic baseado em fetch. Gerencia headers de tenant, idioma, autorização e callbacks de erro.

Instalação

pnpm add @cactus-agents/api-client

Uso básico

import { createApiClient } from '@cactus-agents/api-client';

const client = createApiClient({
baseUrl: 'https://api.example.com/v2',
tenant: 'my-tenant',
language: 'pt-br',
});

const response = await client.get<MyData>('/endpoint');
// response.data, response.status, response.headers

ApiClientConfig

createApiClient aceita FullApiClientConfig = ApiClientConfig & ApiClientRefreshConfig — o bloco de refresh está documentado em Refresh-on-401.

interface ApiClientConfig {
baseUrl: string; // URL base da API
tenant: string; // Header tenant
language: string; // Idioma fallback

// Opcionais
buildId?: string; // ID do build
debug?: boolean; // Habilita header DEBUG
cloudflare?: { env?: any }; // bindings extras do Worker (KV, R2, service bindings)

// Cache
serverCache?: ServerCacheConfig; // memória global + KV
clientCache?: ClientCacheConfig; // localStorage com TTL

// Callbacks de dados
getAccessToken?: () => string | null | undefined;
getLocale?: () => string;
getOriginAccess?: () => OriginAccess;
getReferrerInfo?: () => ReferrerInfo | null;
getExtraHeaders?: () => Record<string, string>;

// Hooks de erro
onUnauthorized?: (url: string, context?: UnauthorizedContext) => void; // 401 e 440
onChallenge?: () => void; // 429
onTimeoutLimit?: (url: string) => void; // 202
}

:::caution onUnauthorized recebe dois argumentos A assinatura ganhou um segundo parâmetro context. Consumidores antigos que só usam url continuam funcionando, mas novo código deve ler o context — é dele que sai o reason do BFF, e sem ele não há classificação granular de erro.

interface UnauthorizedContext {
/** URL (ou path) que devolveu 401 / 440. */
url: string;
/** 401 (token rejeitado pelo middleware) ou 440 (sessão encerrada pelo BFF). */
status: number;
/** Body da resposta do BFF, quando decodificável como JSON. */
data?: unknown;
/** Body cru (truncado). */
rawBody?: string;
/** Headers da resposta. */
headers?: Headers;
}

Isso é o que sustenta a regra do base sobre proxyErrorResponse() / unauthorizedNoToken(): envelope genérico { ok: false, error } em 401/403 descarta o reason e quebra a classificação. Passe o context adiante. :::

Métodos

MétodoDescrição
get<T>(path)GET com headers internos
post<T>(path, payload?)POST com headers internos
put<T>(path, payload?)PUT com headers internos
patch<T>(path, payload?)PATCH com headers internos
delete<T>(path, payload?)DELETE com headers internos
getExternal<T>(url)GET para URLs externas (sem headers internos)

Headers automáticos

Em toda request interna (external !== true), o client adiciona:

HeaderConteúdo
tenant, origin-domainconfig.tenant
lang / languagegetLocale() ou config.language
versionvz3b-{buildId} (ou vm se sem buildId)
X-LOG-INFO1-{Date.now()}-{buildId} — formato textual estável usado por tracing server-side
Authorization: Bearer {token}se getAccessToken() retorna token
X-ORIGIN-ACCESSse getOriginAccess() retorna valor ≠ Unknown
X-ORIGIN-REFERRERgetReferrerInfo()?.referrer (URL do referrer first-touch)
X-ORIGIN-HOSTNAMEgetReferrerInfo()?.hostname
X-ORIGIN-CINFO-IDgetReferrerInfo()?.cinfoId (vindo de X-INFOS-ID que o BFF retorna em login/register)
X-ORIGIN-CINFO-ID-REFgetReferrerInfo()?.cinfoIdRef (vindo de X-INFOS-REF)

O bloco X-ORIGIN-* faz parte do tracking de marketing/atribuição — ver Marketing Tracking pra fluxo end-to-end de captura, política de atribuição e payloads associados.

Tratamento de status

StatusComportamento
200-299Resolve com ApiResponse<T>
202Resolve (não throw) + chama onTimeoutLimit
401Tenta refresh (quando habilitado) → replay; se falhar, chama onUnauthorized(url, context) + throw
440Chama onUnauthorized(url, context) + throw — pula o refresh de propósito
429Chama onChallenge + throw
Outros non-okThrow ApiClientError

:::note Por que 440 não tenta refresh 440 ("Login Time-out") significa que o BFF encerrou a sessão server-side. Um refresh não recupera isso: replay com token "novo" produziria outro 440, em loop de logout. Então 440 vai direto para o handler de logout forçado. :::

O erro lançado é um ApiClientError<T> (ApiResponse<T> + url + rawBody?) — não é instanceof Error. Por isso String(err) resulta em [object Object]; use sempre extractApiError(err).

ApiResponse

interface ApiResponse<T> {
data: T;
status: number;
statusText: string;
headers: Headers;
url?: string;
rawBody?: string;
}

OriginAccess

enum OriginAccess {
Unknown = 0,
App = 1,
Desktop = 2,
DesktopApp = 3,
Mobile = 4,
MobileApp = 5,
}

Refresh-on-401

Opt-in. Sem refreshTokenFn, o comportamento é o antigo: onUnauthorized + throw imediato no 401.

interface ApiClientRefreshConfig {
/** Habilita UMA tentativa de refresh antes de rejeitar no 401. */
enableRefreshRetry?: boolean;
/** Obtém um token novo. Deve resolver quando `getAccessToken()` já devolve o novo valor. */
refreshTokenFn?: () => Promise<void>;
/** Paths que NUNCA disparam refresh (refresh em falha de login viraria loop). */
refreshRetrySkipPaths?: readonly string[];
}

Comportamento:

  • no máximo uma tentativa por request; sucesso replays o request original com o token novo;
  • 401s concorrentes coalescem em um único refresh — uma rajada de requests que expira junto dispara exatamente uma chamada de /users/refresh-token;
  • a skip list default cobre login/logout/refresh do BFF e os equivalentes de proxy do base (/api/auth/login, /api/auth/logout, /api/auth/logout-auto, /api/auth/refresh);
  • a checagem de skip usa o pathname derivado da URL, então é agnóstica ao baseUrl.

Cache (server + client)

Duas camadas independentes, ambas desligadas por default.

interface ApiCacheRule {
ttl: number; // segundos
methods: string[]; // tipicamente ["GET"]
}

interface ServerCacheConfig {
enabled: boolean;
kv?: KVNamespaceLike; // sem KV = só memória
ctx?: ExecutionContextLike; // waitUntil para escrita não-bloqueante em KV
rules?: Record<string, ApiCacheRule>; // chave = prefixo de path do BFF
// ...
}

interface ClientCacheConfig {
enabled: boolean;
rules?: Record<string, ApiCacheRule>;
maxEntries?: number; // default 100 (LRU)
prefix?: string; // default "cac:api:"
cacheKeyPrefix?: string; // namespace global — controlado por env.CACHE_GENERATION
}

Exports relacionados: GlobalCache (singleton em memória, server), LocalStorageCache (client) e DEFAULT_API_CACHE_RULES.

:::caution Não empilhe dois caches na mesma rota ServerCacheConfig tem um kill switch para consumidores que já têm uma camada de cache mais rica em cima (o platform-cache, com tags, SWR e single-flight). Ligar os dois na mesma rota faz as duas camadas divergirem e servirem dados diferentes para o mesmo path — foi exatamente o postmortem de /payment-providers em 2026-05. Escolha uma camada por recurso. :::

O cacheKeyPrefix das duas configs existe para o mesmo fim do cacheGeneration do platform-cache: rotacionar o namespace via env.CACHE_GENERATION sem mudar código.

Classificação de erros

O pacote carrega dois registries e um classificador puro, que produzem o código estável de erro que a UI mostra ao usuário e que aparece nos logs.

import { classifyApiError, extractApiError } from "@cactus-agents/api-client";

try {
await client.post("/endpoint", body);
} catch (err) {
const e = extractApiError(err);
const c = classifyApiError({ status: e.status, data: e.data, url: e.url });
// c.ref → "EP0047-EM0003" (o que o usuário vê)
// c.endpointCode → "EP0047"
// c.errorCode → "EM0003"
// c.i18nKey → chave de tradução da mensagem
// c.reason → string de reason extraída do body do BFF (para log)
}
ExportO que é
classifyApiError(input)Puro, sem side-effect. Casa status + reason contra o ERROR_REGISTRY e resolve o endpoint pela URL
ERROR_REGISTRYTabela de erros. A última entrada é catch-all, então sempre há match
ENDPOINT_REGISTRY / lookupEndpointCode(url)Tabela URL → código de endpoint
ClassifiedError, ClassifyInput, ErrorCodeEntry, EndpointCodeEntry, EndpointCodeLookupTipos

A tabela de códigos e o contrato de exibição estão em error-codes.

Helpers

O pacote exporta helpers de conveniência para quem consome o ApiClient junto com outros pacotes do SDK.

createFetcherFromClient(client)

Converte um ApiClient (ou qualquer objeto com get/post que retorna { data }) no formato de fetcher plano (get/post) esperado por pacotes como @cactus-agents/brand e pelos services de auth/wallet/recovery do @cactus-agents/accounts:

import { createApiClient, createFetcherFromClient } from '@cactus-agents/api-client';
import { createAuthService } from '@cactus-agents/accounts';

const client = createApiClient({ baseUrl, tenant, language });
const fetcher = createFetcherFromClient(client);
const auth = createAuthService(fetcher);

Na maioria dos casos, prefira createAuthFromClient / createBrandFromClient diretamente — eles fazem essa conversão internamente.

createFullFetcherFromClient(client)

Versão estendida que expõe todos os 5 métodos HTTP (get, post, put, patch, delete). Usado por services que precisam de mais que get/post — o UserService do @cactus-agents/accounts, por exemplo, exige patch e delete:

import { createApiClient, createFullFetcherFromClient } from '@cactus-agents/api-client';
import { createUserService } from '@cactus-agents/accounts';

const client = createApiClient({ baseUrl, tenant, language });
const fetcher = createFullFetcherFromClient(client);
const user = createUserService(fetcher);

Na maioria dos casos, prefira createUserFromClient diretamente — ele faz essa conversão internamente.

extractApiError(err)

Normaliza erros do ApiClient (que não são instâncias de Error) em uma estrutura consistente para logging e respostas HTTP:

import { extractApiError } from '@cactus-agents/api-client';

try {
await client.post('/endpoint', body);
} catch (err) {
const { status, data, message, isApiError } = extractApiError(err);
// status: number | undefined
// data: response body (se API error)
// message: string (para logging)
// isApiError: boolean
}

Tipos

interface ClientLike {
get<T>(path: string): Promise<{ data: T }>;
post<T>(path: string, body?: unknown): Promise<{ data: T }>;
}

interface FullClientLike extends ClientLike {
put<T>(path: string, payload?: unknown): Promise<{ data: T }>;
patch<T>(path: string, payload?: unknown): Promise<{ data: T }>;
delete<T>(path: string, payload?: unknown): Promise<{ data: T }>;
}

interface ExtractedApiError {
status: number | undefined;
data: unknown;
message: string;
isApiError: boolean;
url?: string;
statusText?: string;
}

interface ReferrerInfo {
/** URL completa do `document.referrer` external (cross-host) capturado no first-touch. */
referrer: string;
/** Hostname extraído do `referrer`. */
hostname: string;
/** Eco do response header `X-INFOS-ID` recebido em login/register, persistido em cookie pelo template. */
cinfoId?: string;
/** Eco do response header `X-INFOS-REF` recebido em login/register. */
cinfoIdRef?: string;
}

ReferrerInfo carrega referrer + IDs de tracking do BFF, não UTMs. UTMs viajam apenas como campos de body em signup/deposit (ver Marketing Tracking).

createCactusServerClient

Factory de alto nível para uso em Cloudflare Workers e SSR. Encapsula toda a lógica de headers da plataforma Cactus (IP real do cliente, geo CF, cookies, auth, origin-access) para que os loaders precisem apenas passar o env e o Request de entrada.

import { createCactusServerClient } from "@cactus-agents/api-client";

// Em um loader / action:
const client = createCactusServerClient({
env: context.cloudflare.env, // ou process.env em dev local
request,
getAccessToken: () => token,
});

// Agora use normalmente com os wrappers do SDK:
const games = createGamesFromClient(client);
const brand = await createBrandFromClient(client, opts);

CactusServerClientOptions

interface CactusServerClientOptions {
env: Partial<CactusServerEnv>; // vars de ambiente (único obrigatório)
request?: Request; // Request do Worker (extrai IP, geo, cookies, UA)
cfGeoData?: CfGeoData; // geo pré-extraída — tem PRIORIDADE sobre request.cf
cloudflare?: { env?: any }; // bindings extras do Worker
cookies?: string; // override de cookies (substitui request.cookies)
getAccessToken?: () => string | null | undefined;
buildId?: string;
debug?: boolean;
getOriginAccess?: () => OriginAccess; // default: OriginAccess.Desktop
getReferrerInfo?: () => ReferrerInfo | null;
extraHeaders?: Record<string, string>; // headers server-to-server (mesclados DEPOIS dos built-in)

serverCache?: ServerCacheConfig;

// Hooks de erro (mesmos do ApiClientConfig)
onUnauthorized?: (url: string, context?: UnauthorizedContext) => void;
onChallenge?: () => void;
onTimeoutLimit?: (url: string) => void;

// Refresh-on-401
enableRefreshRetry?: boolean;
refreshTokenFn?: () => Promise<void>;
refreshRetrySkipPaths?: readonly string[];
}

:::caution extraHeaders é só para server Merge depois dos headers built-in, então dá para adicionar ou sobrescrever. Serve para segredos server-to-server que nunca podem chegar ao browser — passe apenas de código server (Workers/Node), jamais de bundle client. :::

CfGeoData — por que existe

interface CfGeoData {
ip: string;
country: string;
timezone: string;
longitude: string;
latitude: string;
regionCode: string;
city: string;
}

request.cf é propriedade não-padrão do Cloudflare e pode não sobreviver ao processamento do framework — o React Router cria novos objetos Request que perdem o .cf. Extraindo no entry point do worker e passando cfGeoData, o geo chega garantido ao client. Quando presente, tem prioridade sobre request.cf.

CactusServerEnv

interface CactusServerEnv {
API_BASE_URL: string;
ORIGIN_DOMAIN: string;
BRAND_LANGUAGE: string;
BRAND_COUNTRY: string; // alpha-3 (ex: "BRA")
BRAND_CURRENCY: string;
BRAND_TIMEZONE: string;
CF_WORKER_KEY?: string; // header de autenticação Worker → API (server-only, nunca expor ao browser)

// Acesso de dev a ambientes protegidos
DEV_USER?: string;
DEV_USER_TOKEN?: string;
DEV_ACCESS_ID?: string;
DEV_ACCESS_SECRET?: string;
}

Headers injetados automaticamente

O createCactusServerClient injeta os seguintes headers em cada requisição, além dos headers padrão do ApiClient:

HeaderOrigem
User-Agentrequest.headers["user-agent"]
S-Client-Addr, x-cac-real-ip, X-Forwarded-For, X-Real-IPCF-Connecting-IP do request
x-countryrequest.cf.country (ISO alpha-2)
x-user-info-timezonerequest.cf.timezone
x-user-info-long, x-user-info-latrequest.cf.longitude / request.cf.latitude
x-user-info-region, x-user-info-cityrequest.cf.regionCode / request.cf.city
country, country_alpha3, currency, jurisdictionDerivados das env vars da marca
Cookierequest.headers["Cookie"] (ou options.cookies)
cf-worker-keyenv.CF_WORKER_KEY (quando presente)
cuidado

CF_WORKER_KEY é um segredo server-side. Configure via wrangler secret put CF_WORKER_KEY e nunca inclua no bundle client.

RequestDebugInfo

Tipo da metadata da request capturada apenas quando debug: true no ApiClientConfig (anexada ao ApiResponse.requestInfo). Pra inspeção em DevApiDebug / DevApiExplorer. Não está relacionado ao header X-LOG-INFO, que é um string estável (1-{ts}-{buildId}).

interface RequestDebugInfo {
/** URL completa que foi chamada. */
url: string;
/** Método HTTP. */
method: string;
/** Headers enviados (Authorization e Cookie ficam mascarados como `[REDACTED]`). */
headers: Record<string, string>;
/** Body parseado, `undefined` para GET/bodyless. */
body?: unknown;
}

Exports

O pacote exporta os seguintes itens:

Fonte: packages/api-client/src/index.ts.

ExportTipoDescrição
ApiClientclassClasse do client HTTP
createApiClientfunctionFactory básica para ApiClient (client-side / testes)
createCactusServerClientfunctionFactory para Workers/SSR com headers automáticos de plataforma
createFetcherFromClientfunctionConverte ApiClient para fetcher simples (get/post)
createFullFetcherFromClientfunctionConverte ApiClient para fetcher completo (5 métodos)
extractApiErrorfunctionNormaliza erros do ApiClient
classifyApiErrorfunctionResolve { endpointCode, errorCode, ref, i18nKey }
lookupEndpointCodefunctionURL → código de endpoint
ERROR_REGISTRY, ENDPOINT_REGISTRYconstTabelas de classificação
GlobalCacheclassCache server em memória (singleton)
LocalStorageCacheclassCache client em localStorage com TTL
DEFAULT_API_CACHE_RULESconstRegras de cache default por rota
OriginAccessenumOrigem do acesso
ApiClientConfigtypeConfiguração base do client
ApiClientErrortypeShape lançado em resposta non-2xx
UnauthorizedContexttypeContexto do onUnauthorized (401/440)
ApiResponsetypeResposta padronizada
ExtractedApiErrortypeErro normalizado
RequestDebugInfotypeInformações de debug do request
ReferrerInfotypeReferrer + IDs de tracking do BFF
CactusServerClientOptions, CactusServerEnv, CfGeoDatatypeServer client
ServerCacheConfig, ClientCacheConfig, ApiCacheRule, CachedEntrytypeCache
KVNamespaceLike, ExecutionContextLiketypeShapes mínimos de KV / ExecutionContext
ClassifiedError, ClassifyInput, ErrorCodeEntry, EndpointCodeEntry, EndpointCodeLookuptypeClassificação de erro
ClientLiketypeInterface mínima de client (get/post)
FullClientLiketypeInterface completa de client (5 métodos)