Frontend runtime API URL via next-runtime-env (corrige 404 client-side no k8s)

TLDR: As chamadas de API no client 404am no k8s porque API_URL é inlinada em build time e sai vazia (a imagem do k8s é buildada sem ela). Corrigido com next-runtime-env: o pod serve NEXT_PUBLIC_API_URL lida em RUNTIME (a partir do secret), então o browser chama o domínio real da API diretamente — sem build-arg, sem imagem por ambiente, sem proxy. Também torna permanente o proxy-buffer-size do ingress.

Module: frontend

Contexto

Depois que o frontend do trgclub foi migrado para o k8s (trgclub--staging, beta.staging.trgclub.com), o login funciona mas o app falha em duas coisas:

  1. 502 na navegação pós-login — o cookie de sessão do next-auth tem ~3.7KB e o ingress nginx do frontend-web não tem proxy-buffer-size, então o nginx retorna 502 antes da requisição chegar ao pod. Validado ao vivo: proxy-buffer-size: 16k no ingress de staging corrige (aplicado via kubectl, ainda não está no git → o próximo terraform/delivery apaga a mudança).

  2. 404 nas chamadas de API client-side — as chamadas do browser no dashboard (/features/:id, /me/profile, closest_meetings, latest_meetings, performance) batem em https://beta.staging.trgclub.com/features/... (origem do frontend, sem /api/v1) e 404am. São hooks react-query rodando no browser (ControlPanel.tsx, HistoryPerformance.tsx, services/user, services/meetings) → fetcher → fetchInstance. Causa raiz: src/services/fetch/fetch.ts define baseURL = process.env.API_URL, e next.config.js expõe API_URL/NEXT_PUBLIC_API_URL via o bloco env — ambos inlinam o valor em build time. A imagem do k8s é buildada SEM API_URL (só é injetada em runtime via trgclub-frontend-secrets), então o bundle do client sobe com undefined → o axios cai de volta para a origem atual. Verificado: nenhum chunk servido contém a API URL.

    Chamadas server-side (getServerSideProps em painel-de-controle, authorize do next-auth, route guards) já funcionam — leem process.env.API_URL em runtime no servidor. Só o código do browser está quebrado: process.env não existe no browser, então a API URL precisa viajar do servidor (onde o env existe) para o browser em runtime.

Por que não build-arg / NEXT_PUBLIC_ inlinado: o Netlify builda por site com API_URL presente, então inlinar funciona lá. O pipeline k8s da WeHive (package.yml) builda a imagem uma vez, sem input de environment, e promove a mesma imagem para staging e production. Inlinar a URL de um ambiente em build time faria production chamar a API de staging. Por isso a URL precisa ser resolvida em runtime, mantendo a imagem agnóstica de ambiente.

O CORS já está aberto no backend (access-control-allow-origin: *, headers de auth expostos), então o browser chamando o domínio da API diretamente (beta.staging.api.trgclub.com/api/v1) funciona — sem necessidade de proxy. O requisito é: o browser chama o backend diretamente, com a URL fornecida em runtime.

Objetivos

  • Browser chama o domínio da API do backend diretamente (.../api/v1), com a URL resolvida em runtime — fornecida pelo pod em execução a partir do seu secret, sem inlining em build. Mesma imagem funciona em todo ambiente sem rebuild.
  • Usar next-runtime-env (padrão da comunidade para runtime env no Next; suporta Next 15 + pages-router) em vez de um window.__ENV__/env.js artesanal. A lib serve valores NEXT_PUBLIC_* lidos do processo do pod no momento da requisição.
  • Reaproveitar o secret API_URL existente — o pod deriva NEXT_PUBLIC_API_URL a partir dele no boot (run/runtime), sem adicionar nova chave de secret/terraform (única fonte da verdade).
  • Encapsular em um módulo Env pequeno (Env.get("API_URL")) para que os consumidores nunca toquem na lib ou na convenção NEXT_PUBLIC_ diretamente.
  • Unificar em uma única variável de runtime NEXT_PUBLIC_API_URL, lida via env() em todo lugar (server e client resolvem em runtime). Aposentar o API_URL separado, server-only.
  • Tornar proxy-buffer-size permanente no manifest do ingress do frontend (sobrevive a terraform/delivery), igualando o valor validado em staging.

Fora de escopo

  • Aurora vazia do backend / login 500 — problema separado; o backend de beta temporariamente aponta para o Heroku; migrar os dados para uma Aurora na própria conta AWS do trgclub é um esforço planejado distinto. Não é tratado aqui.
  • CrashLoopBackOff em production beta.trgclub.com — quebra pré-existente separada em production; fora de escopo.
  • Sem mudança de pipeline (sem input environment em package.yml).
  • Sem proxy/rewrite (browser bate na API diretamente, conforme requisito); sem mudança de backend/auth; sem redução do cookie de sessão (o ajuste do buffer cobre o 502).

Mudanças

modules/frontend/package.json

  • Adicionar dependência next-runtime-env.

modules/frontend/pages/_document.tsx

  • Importar PublicEnvScript de next-runtime-env; renderizar <PublicEnvScript /> no <Head> (antes das outras tags de head), para que o pod injete NEXT_PUBLIC_* no momento da requisição (runtime), legível pelo bundle do client.

Nova abstração Env — src/infra/env/

Encapsular next-runtime-env num módulo pequeno para que o resto do código nunca conheça a lib ou a convenção NEXT_PUBLIC_. Consumidores chamam Env.get("API_URL").

```ts // src/infra/env/index.ts import { env } from “next-runtime-env”;

const PUBLIC_PREFIX = “NEXT_PUBLIC_”;

export const Env = { get(key: string): string | undefined { // “API_URL” -> NEXT_PUBLIC_API_URL, resolved at RUNTIME (server + client); // fall back to the bare key for server-only vars. return env(${PUBLIC_PREFIX}${key}) ?? env(key); }, getOrThrow(key: string): string { const value = Env.get(key); if (!value) throw new Error(Missing env: ${key}); return value; }, }; ```

Por que funciona com chave dinâmica: o env() do next-runtime-env lê de um objeto de runtime (client: window.__ENV injetado pelo PublicEnvScript; server: process.env do pod), NÃO via inlining literal em build time do Next — então env(\NEXT_PUBLIC_${key}`) resolve corretamente. Benefícios: esconde a lib (troca num único lugar), esconde o prefixo NEXT_PUBLIC_`, ponto único de validação/log, mockável em testes.

Consumidores usam Env.get(...) (uma única fonte de runtime, sem split por typeof window)

Os três usos colapsam em Env.get("API_URL"), resolvido em runtime em ambos os contextos:

  • src/services/fetch/fetch.ts: const baseURL = Env.get("API_URL") (usado pelo fetcher no client e no server). O axios aponta para o domínio real da API .../api/v1; garantir join de path limpo (sem /api/v1 duplicado, trailing slash tratado).
  • src/infra/NextAPIAuth/index.js: substituir process.env.API_URL por Env.get("API_URL").
  • src/infra/routesGuard/validators/restrictedRoutesValidator/restrictedRoutesForRolesValidator.tsx: substituir process.env.API_URL por Env.get("API_URL").

Aposentar as leituras diretas de process.env.API_URL (um único acessor canônico: Env.get). A variável de runtime é NEXT_PUBLIC_API_URL; a camada Env mapeia a chave amigável "API_URL" para ela.

modules/frontend/next.config.js

  • Remover API_URL e NEXT_PUBLIC_API_URL do bloco env — a lib as lê em runtime; deixá-las em env inlinaria valores de build time e anularia a correção.

modules/frontend/run/runtime — derivar NEXT_PUBLIC_API_URL a partir de API_URL no boot

Em vez de adicionar uma chave nova ao secret, reaproveitar o API_URL existente do secret do k8s e derivar a variável pública quando o pod inicia (única fonte da verdade):

bash #!/usr/bin/env bash set -e export NEXT_PUBLIC_API_URL="${API_URL}" # k8s secret already provides API_URL exec npm run start

No boot isso coloca NEXT_PUBLIC_API_URL no process.env do pod; o PublicEnvScript do next-runtime-env então serve ao client em runtime (a lib só expõe NEXT_PUBLIC_*, então nenhum outro secret vaza). Nenhuma mudança de secret/terraform necessária — API_URL continua a única fonte; a imagem permanece agnóstica de ambiente (o pod de prod deriva a URL de prod a partir do secret de prod).

modules/frontend/.infra/k8s/ — ingress

  • Adicionar ao ingress do frontend (base ou patch de overlay, seguindo onde o host é definido): nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" e nginx.ingress.kubernetes.io/proxy-buffers-number: "4". Torna a correção do 502 permanente.

Como verificar

  • Build: next build passa; grep nos chunks de client buildados — a API URL NÃO deve aparecer (não mais inlinada; é servida em runtime pelo PublicEnvScript).
  • Staging (após deploy): logar em beta.staging.trgclub.com; o dashboard carrega sem 404s — /features/:id, /me/profile, closest_meetings, latest_meetings, performance retornam 200 (aba Network), cada requisição indo diretamente para https://beta.staging.api.trgclub.com/api/v1/....
  • 502 resolvido: /api/auth/session e /app/painel-de-controle retornam 200 com o cookie de sessão completo (a anotação de buffer do ingress já está no manifest).
  • Agnóstico de ambiente: a mesma imagem, dado um NEXT_PUBLIC_API_URL diferente no secret de outro ambiente, faz o browser chamar a API daquele ambiente — sem rebuild. Confirmar inspecionando que o script de env servido em runtime difere por ambiente.

Documentação

  • .project/docs/reference/frontend/frontend_runtime_api_url.md documenta a topologia: API base URL resolvida em runtime via next-runtime-env (NEXT_PUBLIC_API_URL servida pelo pod a partir do seu secret); nunca inlinar API URLs no bundle do client no k8s (modelo build-once/promote). Browser chama o domínio da API diretamente (CORS aberto).
  • Segue wehive:infra (anotações de ingress no .infra/k8s do próprio projeto).