@cactus-agents/utils
Helpers framework-agnostic para uso em Workers, SSR e browser. Cobre detecção de bots, device/OS detection, URLs de imagem e utilitários de player.
Instalação
pnpm add @cactus-agents/utils
Detecção de Bots
isBotUserAgent(ua)
Server-safe. Verifica se uma string de User-Agent corresponde a um bot ou crawler conhecido.
import { isBotUserAgent } from "@cactus-agents/utils";
const ua = request.headers.get("User-Agent") ?? "";
if (isBotUserAgent(ua)) {
// servir página mais leve para Lighthouse/PageSpeed/Googlebot
}
getBotName(ua)
Retorna o nome do bot detectado, ou null se nenhum padrão bateu:
import { getBotName } from "@cactus-agents/utils";
getBotName("Mozilla/5.0 (compatible; Googlebot/2.1)")
// → "googlebot"
getBotName("Mozilla/5.0 (Windows NT 10.0)")
// → null
isBot()
Client-safe. Verifica se o browser atual é um bot — lê navigator.userAgent + flag navigator.webdriver (Selenium/Puppeteer/Playwright). Retorna false no servidor.
import { isBot } from "@cactus-agents/utils";
if (isBot()) {
// não carregar scripts de terceiros pesados
}
Padrões detectados incluem: Googlebot, Chrome Lighthouse, PageSpeed, GTmetrix, Pingdom, WebPageTest, headless Chrome, PhantomJS, Puppeteer, Bingbot, Yandex, Baidu, DuckDuckBot, Twitter/Facebook crawlers, SEMrush, Ahrefs e outros.
Detecção de Device
Todas as funções de device são client-safe — retornam false no servidor.
isIOS()
import { isIOS } from "@cactus-agents/utils";
isIOS() // true em iPhone, iPad (incluindo iPadOS 13+ que reporta como Macintosh)
Detecta iPadOS 13+ verificando Macintosh no UA + navigator.maxTouchPoints > 1.
isAndroid()
import { isAndroid } from "@cactus-agents/utils";
isAndroid() // true em dispositivos Android
isIOSSafari()
import { isIOSSafari } from "@cactus-agents/utils";
isIOSSafari() // true apenas em iOS + Safari nativo (não Chrome/Firefox/Edge no iOS)
Exclui CriOS (Chrome), FxiOS (Firefox), OPiOS (Opera) e EdgiOS (Edge).
isRunningAsApp()
import { isRunningAsApp } from "@cactus-agents/utils";
isRunningAsApp() // true quando em modo standalone (PWA instalada)
Detecta navigator.standalone (iOS) e (display-mode: standalone) (Android/desktop).
isGooglePlayUrl(url)
import { isGooglePlayUrl } from "@cactus-agents/utils";
isGooglePlayUrl("https://play.google.com/store/apps/details?id=com.example")
// → true
getDesktopOs()
import { getDesktopOs } from "@cactus-agents/utils";
getDesktopOs(); // "mac" | "windows" | "linux" | "unknown"
Retorna "unknown" no servidor, em mobile (Android/iOS) ou quando a UA não classifica. iPadOS reportando como Macintosh é excluído — só devolve mac quando não é iPadOS.
getUserAgentPlatform()
import { getUserAgentPlatform } from "@cactus-agents/utils";
getUserAgentPlatform(); // "mac" | "windows" | "linux" | "android" | "ios" | "unknown"
Identificador unificado de OS, com precedência: (1) navigator.userAgentData.platform (UA Client Hints), (2) parsing da UA string, (3) "unknown".
isTouchPrimary()
import { isTouchPrimary } from "@cactus-agents/utils";
isTouchPrimary(); // true quando o ponteiro primário é coarse (touch)
Implementado via matchMedia("(pointer: coarse)"). Serve para desambiguar "Request Desktop Site" no Android (UA de desktop, mas com touch) e distinguir laptop com mouse de tablet/celular.
Imagens
:::danger Transform de Cloudflare vai no PATH, nunca em querystring
O Cloudflare ignora quality/format em querystring. Medição contra a CDN real (2026-08-04): …/w=240, …/w=240?quality=60 e …/w=240?quality=20 devolvem bytes idênticos (16.3KB), enquanto …/width=240,quality=60,format=auto devolve 9.2KB — 43% menos pela mesma imagem.
Por isso o formato produzido pelo pacote é sempre um path com lista separada por vírgula:
{base}/width=240,quality=80,format=auto
{base}/width=160,height=200,fit=cover,quality=80,format=auto
Nunca w=N e nunca params na query. Se você vê ?quality= numa URL de imagem transformada, é bug.
:::
getImageUrl(image, options?)
Constrói a URL correta para uma imagem, detectando o tipo automaticamente:
import { getImageUrl } from "@cactus-agents/utils";
// Cloudflare Image Delivery com width → transform no path, via proxy same-origin
getImageUrl("https://imagedelivery.net/abc/image-id/public", { width: 400 })
// → "/cdn-cgi/imagedelivery/abc/image-id/width=400,quality=80,format=auto"
// Com height → ganha `fit` (default "cover")
getImageUrl("https://imagedelivery.net/abc/image-id/public", { width: 160, height: 200 })
// → "/cdn-cgi/imagedelivery/abc/image-id/width=160,height=200,fit=cover,quality=80,format=auto"
// SEM width → cai na variante NOMEADA `public`
getImageUrl("https://imagedelivery.net/abc/image-id/public")
// → "/cdn-cgi/imagedelivery/abc/image-id/public?quality=80&format=auto"
// URL externa — retorna como está
getImageUrl("https://external.com/banner.jpg")
// → "https://external.com/banner.jpg"
// Thumb legada com /mobile ou /ipad — devolve o path base compartilhado
getImageUrl("https://cdn.example.com/games/fortune/mobile")
// → "https://cdn.example.com/games/fortune"
// Path relativo — adiciona prefixo /api/storage/
getImageUrl("uploads/logo.png")
// → "/api/storage/uploads/logo.png"
// Path já absoluto (começa com "/"), data: ou blob: — retorna como está
getImageUrl("/images/logo.svg")
// → "/images/logo.svg"
// null/undefined — retorna undefined
getImageUrl(null)
// → undefined
:::caution Sem width não há transform
No caminho public os params de querystring são inertes — a qualidade e o formato vêm da configuração da variante no dashboard do Cloudflare. Eles são mantidos apenas para não trocar a cache-key de todas as brands. Não tente ajustar qualidade por ali: passe width para entrar no caminho de path.
:::
buildImageSrcSet(image, widths, options?)
Gera srcset responsivo:
imagedelivery.net→ uma entrada por width, com transform no path- thumbs legadas com
/mobilee/ipad→ srcset das duas variantes - imagens sem variantes (externas,
data:,blob:, locais) →undefined
import { buildImageSrcSet } from "@cactus-agents/utils";
buildImageSrcSet("https://imagedelivery.net/abc/image-id/public", [320, 640]);
// → "/cdn-cgi/imagedelivery/abc/image-id/width=320,quality=80,format=auto 320w, …640w"
As widths são normalizadas antes de gerar: arredondadas, deduplicadas, valores <= 0 removidos e ordenadas crescente.
getResponsiveImageProps(image, options)
Retorna um objeto pronto para <img> com src, srcSet e sizes.
import { getResponsiveImageProps } from "@cactus-agents/utils";
const image = getResponsiveImageProps(banner.image, {
widths: [640, 960, 1280],
fallbackWidth: 960,
sizes: "(max-width: 640px) 100vw, 1280px",
});
Com aspectRatio, cada width do srcset recebe uma altura proporcional (height = round(width / aspectRatio)) e fit cai em "cover", ou seja o Cloudflare recorta nas dimensões exatas:
getResponsiveImageProps(url, { widths: [160, 240, 320], aspectRatio: 4 / 5 });
// srcset: ".../width=160,height=200,fit=cover,... 160w, .../width=240,height=300,... 240w, ..."
fallbackWidth default é a maior width. Quando sizes não é passado e existe srcset, o valor cai em "100vw".
:::danger Não use isto direto para arte de jogo
No front-web-base, game.image tem perfil único: use getGameArtworkProps(image, sizes) de ~/utils/game-artwork. O transform é codificado no path, então uma ladder de widths divergente faz a MESMA arte ser baixada duas vezes. Não passe aspectRatio/height para arte de jogo — o recorte é CSS.
:::
getAssetsUrlForCDN(image, cdnBaseUrl?, options?)
Prepends CDN base URL a assets locais:
import { getAssetsUrlForCDN } from "@cactus-agents/utils";
// Path local → CDN
getAssetsUrlForCDN("/assets/icon.png", "https://cdn.example.com")
// → "https://cdn.example.com/assets/icon.png"
// URL externa → passa direto
getAssetsUrlForCDN("https://external.com/img.jpg", "https://cdn.example.com")
// → "https://external.com/img.jpg"
getImageDefaults() / setImageDefaults(defaults)
Defaults globais do processo. Chame setImageDefaults uma vez, na inicialização, antes de qualquer componente renderizar:
import { setImageDefaults } from "@cactus-agents/utils";
setImageDefaults({ quality: 75 });
Valores default de fábrica: { quality: 80, format: "auto", useProxy: true }.
useProxy controla como URLs do Cloudflare Image Delivery são reescritas:
| Valor | Resultado |
|---|---|
true (default) | Proxy same-origin: /cdn-cgi/imagedelivery/{hash}/{id}/… |
false | URL absoluta https://imagedelivery.net/{hash}/{id}/… — usado em dev local, onde não há Cloudflare na frente |
string | Prefixo same-origin custom: {prefixo}{imageId}/… |
Progressive image loading
enableProgressiveImageLoading(config?) e disableProgressiveImageLoading() são exportados, com ProgressiveImageConfig (threshold default 70, targetQuality default 90).
:::warning enableProgressiveImageLoading é no-op hoje
A implementação está inteiramente comentada no source: a função retorna imediatamente. Chamar não faz nada e não quebra nada, mas não espere upgrade de qualidade em duas etapas. Não construa feature em cima disso sem antes reativar o pacote.
:::
Debug
:::caution Removido do pacote
activateDebugFromUrl() e isDebugActive() não existem mais — foram removidos pelo commit breaking 5cf7444 (2026-05-06), que deletou packages/utils/src/debug.ts. Importar qualquer um dos dois falha.
O mecanismo de debug hoje é do front-web-base, não do SDK — não há substituto neste pacote.
:::
Player
maskPlayerName(fullName)
Mascara o nome completo de um jogador para exibição pública:
import { maskPlayerName } from "@cactus-agents/utils";
maskPlayerName("Joao Silva Santos")
// → "Joao Si**"
maskPlayerName("Maria")
// → "Ma**"
Formato: primeiro nome + 2 primeiras letras do segundo nome + **.
Tipos
interface ImageOptions {
width?: number;
height?: number;
/** Modo de recorte (default "cover"). Só significa algo com `height`. */
fit?: "cover" | "contain" | "scale-down" | "crop" | "pad";
format?: string;
quality?: number;
}
interface ResponsiveImageOptions extends Omit<ImageOptions, "width" | "height"> {
widths: number[];
sizes?: string;
fallbackWidth?: number;
/** width/height. Quando setado, a altura é calculada por width. */
aspectRatio?: number;
}
interface ResponsiveImageProps {
src?: string;
srcSet?: string;
sizes?: string;
}
interface ImageDefaults {
quality: number;
format: string;
useProxy: boolean | string;
}
interface ProgressiveImageConfig {
threshold?: number;
targetQuality?: number;
}
type DesktopOs = "mac" | "windows" | "linux" | "unknown";
type OsId = "mac" | "windows" | "linux" | "android" | "ios" | "unknown";
ResponsiveImageOptions faz Omit de "width" e "height" (as duas), e ResponsiveImageProps.src é opcional — pode ser undefined quando a imagem de entrada é nula.
Exports completos
Fonte: packages/utils/src/index.ts.
| Export | Categoria | Descrição |
|---|---|---|
isBot | Bot | Detecta bot no browser (lê navigator) |
isBotUserAgent | Bot | Detecta bot a partir de string UA (server-safe) |
getBotName | Bot | Retorna nome do bot ou null |
isIOS | Device | iOS (incluindo iPadOS 13+) |
isAndroid | Device | Android |
isIOSSafari | Device | iOS + Safari nativo |
isRunningAsApp | Device | PWA standalone |
isGooglePlayUrl | Device | URL do Google Play |
getDesktopOs | Device | OS de desktop (DesktopOs) |
getUserAgentPlatform | Device | OS unificado (OsId), Client Hints primeiro |
isTouchPrimary | Device | Ponteiro primário é touch |
getImageUrl | Image | URL de imagem com transform no path |
buildImageSrcSet | Image | srcset responsivo |
getResponsiveImageProps | Image | Props prontas para <img> |
getAssetsUrlForCDN | Image | Prepend CDN para assets locais |
getImageDefaults | Image | Lê defaults globais de imagem |
setImageDefaults | Image | Define defaults globais de imagem |
enableProgressiveImageLoading | Image | Upgrade progressivo de qualidade (no-op hoje) |
disableProgressiveImageLoading | Image | Desliga o upgrade progressivo |
maskPlayerName | Player | Mascara nome para exibição pública |
Tipos exportados: DesktopOs, OsId, ImageDefaults, ImageOptions, ProgressiveImageConfig, ResponsiveImageOptions, ResponsiveImageProps.