Services
app/services/ separa serviços server-side (*.server.ts) de client-side (*.client.ts). O
critério não é estilístico:
| Sufixo | Onde roda | Token | Padrão |
|---|---|---|---|
*.server.ts | Loaders, actions, middleware | Lê o JWT HttpOnly do request | Fala com o BFF via ApiClient |
*.client.ts | Browser | Não vê o token | Faz fetch numa rota interna /api/* same-origin |
| sem sufixo | Ambos (dados puros/config) | — | Ex: profiles.ts, promotions.ts |
:::danger O browser nunca fala com o BFF direto
API_BASE_URL é server-only por contrato (ver Environment Variables). Um
*.client.ts que precise de dados do BFF sempre passa por uma rota /api/*.
:::
api.server.ts — ApiClient SSR
export function createClient(
cloudflareEnv?: Partial<Env>,
options?: {
request?: Request;
cookies?: string;
debug?: boolean;
ctx?: ExecutionContext;
/** Headers server-to-server extras (ex: `{ "x-api-key": "…" }`). Nunca valores que possam vazar ao browser. */
extraHeaders?: Record<string, string>;
} & Partial<Pick<ApiClientConfig, "getAccessToken">>,
): ApiClient
Delega toda a lógica de header ao createCactusServerClient do
@cactus-agents/api-client — o base só fornece config de ambiente e o request.
Resolução de config: cloudflareEnv?.X ?? process.env.X ?? <default>, campo por campo.
Comportamentos que valem saber:
ExecutionContexté auto-extraído decloudflareEnv.__executionCtx(injetado pelo entry do worker). Não é preciso passarctxmanualmente.X-ORIGIN-ACCESSderiva do User-Agent:OriginAccess.Mobilequando o UA é mobile,OriginAccess.Desktopcaso contrário. (Não é fixo em Desktop.) A distinção app vs browser precisaria de um client hint que ainda não é enviado.- Cache server-side delegado:
serverCache.delegateCaching: true— a camada de cache própria do api-client é bypassada em favor do platform-cache engine, para não ter dois TTLs em paralelo. Ver Caching. X-LOG-INFOcarrega oBUILD_IDem toda request de saída, o que amarra o rastro do BFF ao mesmo id usado nos namespaces de cache.
export async function loader({ context, request }: Route.LoaderArgs) {
const client = createClient(context.cloudflare?.env, { request });
}
brand.server.ts — Brand Loading
export function loadBrandConfigForRequest(
request: Request,
opts: { cloudflareEnv?: Partial<Env>; engine?: CacheEngine; waitUntil?: (p: Promise<unknown>) => void; context?: any },
): Promise<BrandResult>
export async function loadBrandConfig(
envOrOpts?: Partial<Env> | LoadBrandConfigOptions,
engine?: CacheEngine,
): Promise<BrandResult>
loadBrandConfigForRequest é a variante usada no loader do _layout: dedup por request via
getRequestCache(context), então múltiplos loaders no mesmo request reaproveitam a mesma Promise.
type BrandResult =
| { ok: true; brand: BrandConfig; fromCache: boolean }
| { ok: false; error: BrandError };
export interface BrandError {
message: string;
status?: number;
detail?: string;
}
Cache: engine.fetch("brandConfig", …) no platform-cache engine (não existe mais um
ServerCache/getBrandCache nem uma env var de TTL). TTL e SWR vêm da policy do front-ops — hoje
60s + SWR 300s. Passar waitUntil habilita a revalidação em background.
const result = await loadBrandConfigForRequest(request, { cloudflareEnv: cf, waitUntil, context });
if (!result.ok) {
// renderiza a página de erro de brand a partir de result.error
}
platform-cache.server.ts — engine de cache
export function createPlatformCacheEngine(env?: Partial<Env>): CacheEngine
Único ponto de criação do engine. Guarda o CacheApiStore como singleton por generation em
escopo de módulo (preserva o tier de memória entre requests do mesmo isolate). Sem
PLATFORM_CACHE_POLICY_JSON o engine entra em bypass e chama os fetchers direto — é o
comportamento normal em dev local. Detalhes em Caching.
Games
// games.server.ts
export function resolveCassinoMode(cf?: Partial<Env>): CassinoMode // "legacy" | "api_new" | "mixed"
export function createGamesCacheService(opts: CreateGamesCacheOptions): GamesCacheService
:::caution O export é createGamesCacheService, não getGamesCacheService
import { createGamesCacheService } from "~/services/games.server";
export async function loader({ context, request }: Route.LoaderArgs) {
const cf = context?.cloudflare?.env;
const svc = createGamesCacheService({
cloudflareEnv: cf,
request,
waitUntil: context?.cloudflare?.ctx?.waitUntil.bind(context.cloudflare.ctx),
});
return { home: await svc.getHome() };
}
:::
A factory resolve o CASSINO_MODE e, com ele, de onde vêm as rows de cada página:
| Modo | home | casino | casino live |
|---|---|---|---|
legacy | config estático | config estático | config estático |
api_new | BFF (getPageRows) | BFF | BFF |
mixed | BFF | config estático | config estático |
games.cache.server.ts exporta a classe GamesCacheService, que envolve o serviço de games com o
platform-cache engine. Métodos principais: getHome, getCasinoRows, getCasinoLiveRows,
getBase, getAllGames, getByCategory, getByProvider, getDetail, getListPage,
resolveGamesBySlugs, getSuggestions, search, getTopGames, getHighPayers, getTopWins,
getLastWins, getGameTopWins, getStats / getStatsRaw / getStatsBatch /
getStatsBatchFromRows, além das variantes *FromCacheOnly e purge / purgeAll.
Auth
auth.server.ts resolve o perfil do usuário a partir do cookie HttpOnly:
export function getAuthForRequest(…) // perfil resolvido ou null
export function getAuthFetchResult(…) // { profile, errorStatus?, errorData? }
export async function primeAuthTokenCache(token, profile): Promise<void>
export async function invalidateAuthTokenCacheForRequest(request: Request): Promise<void>
getAuthFetchResult existe para que /api/auth/profile possa ecoar o status real do BFF
(401, 440, 500, 0/network) em vez de mascarar tudo como 401. Há um cache cross-request escopado por
token, com TTL de 10s, chaveado por SHA-256 do token (nunca pelo token cru) — ele resolve a rajada
pós-login em que login, useAuthProfileSync e o revalidate do loader batem no perfil quase
simultaneamente.
:::caution auth.client.ts não é um singleton de AuthService
O arquivo existe, mas seu único export é requestRegisterValidatePhone. Não há
initAuthService/getAuthService; o estado de auth vive em useAccountsStore — ver
State Management.
:::
Inventário completo
*.server.ts
| Arquivo | Papel |
|---|---|
api.server.ts | createClient — ApiClient para SSR |
auth.server.ts | Resolução de perfil + cache de token |
brand.server.ts | loadBrandConfig / loadBrandConfigForRequest |
platform-cache.server.ts | createPlatformCacheEngine |
games.server.ts | resolveCassinoMode, createGamesCacheService |
games.cache.server.ts | Classe GamesCacheService |
favorites.server.ts | createFavoritesService — wrapper do BFF de favoritos |
favorites.cache.server.ts | FavoritesCacheService + createFavoritesCacheService (chave por userId) |
contents.server.ts | Áreas de conteúdo WordPress: resolveContentArea, fetchAreaPosts, fetchAreaPostBySlug, fetchAllAreaPosts, fetchRelatedAreaPosts, resolveCanonicalCategorySlug, CONTENT_AREAS |
contents-page.server.ts | loadContentList / loadContentEntry — loaders prontos das páginas de conteúdo |
legal-terms.server.ts | fetchLegalTerms (usado deferred pelo footer) |
sitemap-config.server.ts | resolveGroupSitemap, listBrandSitemapGroupPaths, applyRouteOverrides |
sitemap-content.server.ts | buildContentSitemapChildren |
kyc.server.ts | createKycService |
sports.server.ts | createSportsService, isSportsProviderActive |
rogue.server.ts | fetchRogueToken, resolveRogueApiUrl, ROGUE_PROXY_PREFIX |
smartico.server.ts | generateSmarticoUserHash |
logger.server.ts | createLogger(namespace) — logging estruturado |
*.client.ts (e módulos isomórficos)
| Arquivo | Papel |
|---|---|
auth.client.ts | requestRegisterValidatePhone |
validation.client.ts | Validações: envio/verificação de e-mail e SMS, troca de e-mail/telefone, submit de docs/endereço/nome completo, aceite de termos |
userLimits.client.ts | updateUserLimits, updatePendingData |
identity.client.ts | Fluxo de identidade Chile: validateRut, confirmBirthDate, verifyIdentity |
ftd-cashback.client.ts | verifyEligibility, sendCashback |
smartico-checkin.client.ts | loadCheckinMissionsFromService |
strategy-campaign-api.client.ts | createCampaignApiService |
campaign-feature-flag.client.ts | createCampaignFeatureFlagService |
legacy-feature-flag.client.ts | fetchLegacyFeatureFlag, clearFeatureFlagCache (cache in-memory limpo no logout) |
profiles.ts | listActiveProfiles, findProfileBySlug (dados de config, sem rede) |
promotions.ts | Tipos WpPost + getPromotionImageUrl |
:::info Serviços que não existem mais
sports.client.ts, user.client.ts, games.client.ts, authProfile.client.ts, kyc.client.ts e
cache.server.ts foram removidos. Não há mais um ServerCache genérico nem singletons
initXService/getXService — o cache é o platform-cache engine e o estado de usuário é o
useAccountsStore.
:::
Error handling
Duas regras obrigatórias. Ambas existem porque a alternativa destrói informação de diagnóstico.
1. extractApiError(), nunca String(err)
import { extractApiError } from "@cactus-agents/api-client";
try {
const data = await client.get("/users/wallet");
} catch (err) {
const { status, data, message } = extractApiError(err);
log.error("wallet fetch failed", { status, message });
}
String(err) num erro do ApiClient resulta em [object Object]. O helper está importado em 48
arquivos de app/ hoje.
2. proxyErrorResponse() / unauthorizedNoToken() nas rotas /api/*
import { proxyErrorResponse, unauthorizedNoToken } from "~/utils/proxy-error.server";
import { getTokenFromRequest } from "~/utils/cookie.server";
export async function action({ request, context }: Route.ActionArgs) {
const token = getTokenFromRequest(request);
if (!token) return unauthorizedNoToken();
try {
const data = await client.get("/users/wallet");
return Response.json({ ok: true, data });
} catch (err) {
return proxyErrorResponse(err, "wallet_fetch_failed");
}
}
O que o helper faz:
| Status do BFF | Comportamento |
|---|---|
| 401 / 403 / 440 (auth-sensitive) | Passa o body original do BFF intacto. Sem body utilizável, emite { reason: "unauthorized" }. extraBody é deliberadamente ignorado aqui — um merge corromperia os campos canônicos |
| Demais (4xx não-auth, 5xx) | Mantém o envelope { ok: false, error: "<fallbackName>", detail: <message> } (+ extraBody mergeado depois das chaves canônicas) |
:::danger Nunca devolva { ok: false, error, detail } num 401/403
O classifier do front (classifyApiError) prioriza reason → code → error → message → detail.*.
Um envelope próprio faz o error: "x_failed" ganhar, o classifier nunca olha o detail e todo 401
do BFF cai no fallback genérico EM0005 — perdendo o sinal de triagem que separa "token expirou" de
"IP mudou", "device mudou" ou "liveness obrigatório".
:::
unauthorizedNoToken() devolve { reason: "no_token" } com 401, que bate o regex de EM0001 — o
usuário vê "cookie sumiu" em vez de um erro genérico.
Adoção e exceções: 68 arquivos em app/routes/api/ usam proxyErrorResponse hoje. As rotas de
fluxo pré-auth ficam de fora por natureza (auth/login, auth/register, auth/recovery,
auth/social.$provider, auth/validate-document, auth/documents.*), assim como rotas públicas de
leitura e as administrativas de cache/versão. Doc completa do envelope em
Endpoints > Error codes.