Pular para o conteúdo principal

@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 /mobile e /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:

ValorResultado
true (default)Proxy same-origin: /cdn-cgi/imagedelivery/{hash}/{id}/…
falseURL absoluta https://imagedelivery.net/{hash}/{id}/… — usado em dev local, onde não há Cloudflare na frente
stringPrefixo 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.

ExportCategoriaDescrição
isBotBotDetecta bot no browser (lê navigator)
isBotUserAgentBotDetecta bot a partir de string UA (server-safe)
getBotNameBotRetorna nome do bot ou null
isIOSDeviceiOS (incluindo iPadOS 13+)
isAndroidDeviceAndroid
isIOSSafariDeviceiOS + Safari nativo
isRunningAsAppDevicePWA standalone
isGooglePlayUrlDeviceURL do Google Play
getDesktopOsDeviceOS de desktop (DesktopOs)
getUserAgentPlatformDeviceOS unificado (OsId), Client Hints primeiro
isTouchPrimaryDevicePonteiro primário é touch
getImageUrlImageURL de imagem com transform no path
buildImageSrcSetImagesrcset responsivo
getResponsiveImagePropsImageProps prontas para <img>
getAssetsUrlForCDNImagePrepend CDN para assets locais
getImageDefaultsImageLê defaults globais de imagem
setImageDefaultsImageDefine defaults globais de imagem
enableProgressiveImageLoadingImageUpgrade progressivo de qualidade (no-op hoje)
disableProgressiveImageLoadingImageDesliga o upgrade progressivo
maskPlayerNamePlayerMascara nome para exibição pública

Tipos exportados: DesktopOs, OsId, ImageDefaults, ImageOptions, ProgressiveImageConfig, ResponsiveImageOptions, ResponsiveImageProps.