Pular para o conteúdo principal

Services

app/services/ separa serviços server-side (*.server.ts) de client-side (*.client.ts). O critério não é estilístico:

SufixoOnde rodaTokenPadrão
*.server.tsLoaders, actions, middlewareLê o JWT HttpOnly do requestFala com o BFF via ApiClient
*.client.tsBrowserNão vê o tokenFaz fetch numa rota interna /api/* same-origin
sem sufixoAmbos (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 de cloudflareEnv.__executionCtx (injetado pelo entry do worker). Não é preciso passar ctx manualmente.
  • X-ORIGIN-ACCESS deriva do User-Agent: OriginAccess.Mobile quando o UA é mobile, OriginAccess.Desktop caso 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-INFO carrega o BUILD_ID em 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:

Modohomecasinocasino live
legacyconfig estáticoconfig estáticoconfig estático
api_newBFF (getPageRows)BFFBFF
mixedBFFconfig estáticoconfig 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

ArquivoPapel
api.server.tscreateClientApiClient para SSR
auth.server.tsResolução de perfil + cache de token
brand.server.tsloadBrandConfig / loadBrandConfigForRequest
platform-cache.server.tscreatePlatformCacheEngine
games.server.tsresolveCassinoMode, createGamesCacheService
games.cache.server.tsClasse GamesCacheService
favorites.server.tscreateFavoritesService — wrapper do BFF de favoritos
favorites.cache.server.tsFavoritesCacheService + createFavoritesCacheService (chave por userId)
contents.server.tsÁreas de conteúdo WordPress: resolveContentArea, fetchAreaPosts, fetchAreaPostBySlug, fetchAllAreaPosts, fetchRelatedAreaPosts, resolveCanonicalCategorySlug, CONTENT_AREAS
contents-page.server.tsloadContentList / loadContentEntry — loaders prontos das páginas de conteúdo
legal-terms.server.tsfetchLegalTerms (usado deferred pelo footer)
sitemap-config.server.tsresolveGroupSitemap, listBrandSitemapGroupPaths, applyRouteOverrides
sitemap-content.server.tsbuildContentSitemapChildren
kyc.server.tscreateKycService
sports.server.tscreateSportsService, isSportsProviderActive
rogue.server.tsfetchRogueToken, resolveRogueApiUrl, ROGUE_PROXY_PREFIX
smartico.server.tsgenerateSmarticoUserHash
logger.server.tscreateLogger(namespace) — logging estruturado

*.client.ts (e módulos isomórficos)

ArquivoPapel
auth.client.tsrequestRegisterValidatePhone
validation.client.tsValidaçõ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.tsupdateUserLimits, updatePendingData
identity.client.tsFluxo de identidade Chile: validateRut, confirmBirthDate, verifyIdentity
ftd-cashback.client.tsverifyEligibility, sendCashback
smartico-checkin.client.tsloadCheckinMissionsFromService
strategy-campaign-api.client.tscreateCampaignApiService
campaign-feature-flag.client.tscreateCampaignFeatureFlagService
legacy-feature-flag.client.tsfetchLegacyFeatureFlag, clearFeatureFlagCache (cache in-memory limpo no logout)
profiles.tslistActiveProfiles, findProfileBySlug (dados de config, sem rede)
promotions.tsTipos 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 BFFComportamento
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.