Env de runtime servido por rota (corrige o 404 que o fix anterior não resolveu)

TLDR: o fix anterior adotou next-runtime-env@3.x, que é uma lib de App Router e nunca funcionou no Pages Router — window.__ENV nunca é emitido, então Env.get("API_URL") devolve undefined no browser e as chamadas caem na origem do frontend (404). A correção é remover a lib e servir o env por uma rota de API lida em request time, único mecanismo que funciona igual em página estática e em página SSR.

Module: frontend

Contexto

O spec 20260806003006 resolveu o problema certo — imagem agnóstica de ambiente, API URL resolvida em runtime — com o mecanismo errado. Ele afirma que next-runtime-env “suporta Next 15 + pages-router”. Não suporta. Essa premissa não foi verificada na época e é a causa raiz deste spec.

O sintoma segue vivo em beta.staging.trgclub.com:

GET https://beta.staging.trgclub.com/me/latest_meetings → 404

A chamada deveria sair para beta.staging.api.trgclub.com/api/v1/me/latest_meetings. Como o baseURL do axios é undefined, ela vira relativa e bate na origem do frontend.

Dois problemas empilhados

1. O fix nunca foi deployado. A imagem em staging é trgclub-frontend:v0.1.12 = commit d77c80b (03/08), anterior a todo o trabalho. O bundle servido não contém __ENV nem next-runtime-env. Portanto o 404 atual é o bug original intacto — e isso é resolvido por deploy, não por código.

2. O fix, quando deployado, não funcionaria. Verificado localmente por A/B com buildId conferido nos dois lados.

Causa raiz do (2)

Evidência Como foi verificado
next-runtime-env@3.x é a linha de App Router; Pages Router é a 1.x (Next 12/13) tabela de compatibilidade no README do repo
latest é 3.3.0, publicado em 2025-03-29 — sem release há mais de um ano registro npm
dependencies e peerDependencies = next: ^14; o app roda 15.5.22 registro npm
npm aninha um next@14.2.35 dentro da lib; o next/script dela resolve para essa cópia require.resolve a partir do diretório da lib
node_modules/next-runtime-env ocupa 212 MB por causa da cópia aninhada du -sh

<PublicEnvScript /> renderiza um next/script com strategy: 'beforeInteractive'. Esse mecanismo depende do componente se registrar no runtime do Next que está renderizando o _document. Como são duas instâncias distintas de Next, o registro nunca chega — nada é emitido no HTML.

A falha é silenciosa por causa da própria lib:

js // helpers/is-browser.js function isBrowser() { return Boolean(typeof window !== 'undefined' && window['__ENV']); }

Sem __ENV, isBrowser() devolve false dentro do browser. O env() então segue pelo ramo de servidor e lê process.env[key] — que no bundle client foi substituído em build time por undefined. Nenhum erro, nenhum log: só undefined.

mermaid flowchart LR A["window.__ENV ausente"] --> B["isBrowser() = false"] B --> C["env() lê process.env<br/>(inlinado vazio no build)"] C --> D["baseURL: undefined"] D --> E["GET /me/latest_meetings<br/>na origem do frontend → 404"]

Por que disableNextScript não basta

disableNextScript é o escape hatch da lib: emite um <script> puro em vez do next/script, contornando o Next duplicado. Corrige as páginas SSR — e só elas.

O app tem 15 páginas estaticamente otimizadas, cujo HTML é gerado em build time. Nelas o _document roda no build, quando API_URL não existe. Testado, buildando sem API_URL:

.next/server/pages/index.html window['__ENV'] = {} .next/server/pages/404.html window['__ENV'] = {} .next/server/pages/login/aluno.html window['__ENV'] = {}

pages/login/aluno/index.tsx não tem data fetching, portanto é estática — e é uma página de login, que precisa da API no client. Depois de uma navegação client-side a partir de qualquer entrada estática, window.__ENV continua vazio e o 404 persiste.

Qualquer solução que grave o env no HTML em tempo de render herda essa falha.

Objetivos

  • Env.get("API_URL") resolve o valor de runtime em todas as páginas — estáticas e SSR — sem rebuild por ambiente.
  • Remover next-runtime-env do projeto, junto com o next@14 aninhado e os 212 MB.
  • Manter o contrato público existente: consumidores continuam chamando Env.get("API_URL"), sem saber de onde o valor vem.
  • Preservar a otimização estática das 15 páginas atuais.
  • Falhar ruidosamente. A ausência do valor precisa produzir erro observável, nunca undefined silencioso — foi o silêncio que deixou o bug atual passar por teste, lint e build todos verdes.
  • Normalizar a base URL uma vez, encerrando a mistura de ${API_URL}path e ${API_URL}/path que hoje quebra quatro consumidores.
  • Deixar um teste que falhe se o mecanismo regredir em página estática.

Fora de escopo

  • Alterar o ingress ou o secret trgclub-frontend-secrets — a cadeia de infra está correta e verificada (API_URL=https://beta.staging.api.trgclub.com/api/v1 no secret).
  • O fix de proxy-buffer-size do ingress (502), que é independente e continua válido.
  • Migrar o app para App Router.
  • Expor qualquer variável além de API_URL. Achado adjacente: o código lê outras seis (NEXT_PUBLIC_GATEWAY_URL, NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_TRG_ENVIRONMENT, NEXT_PUBLIC_PRO_ENABLED, NEXT_PUBLIC_DEV_MODE, NEXT_PUBLIC_ALLOWED_PIDS) direto de process.env, e nenhuma existe no secret de staging — hoje todas resolvem undefined em k8s. É um problema pré-existente e independente; a allowlist da rota torna a inclusão futura de cada uma uma linha.

Decisão

Servir o env por uma rota de API lida em request time, carregada por um <script src> bloqueante no <Head> do _document.

mermaid flowchart TD A["secret → pod<br/>API_URL"] --> C["pages/api/env<br/>allowlist, lê process.env por requisição<br/>Cache-Control: no-store"] D["_document &lt;Head&gt;<br/>&lt;script src='/api/env'&gt;"] -->|"bloqueia o parse"| C C --> E["window.__ENV definido"] E --> F["chunks do Next (defer)<br/>executam depois"] F --> G["Env.get('API_URL')<br/>resolve em toda página"]

A ordem é garantida: um <script src> clássico no <head>, sem async/defer, bloqueia o parse e executa antes dos chunks do Next, que são defer. Vale igualmente para HTML estático e para HTML renderizado por requisição, porque o valor não está no HTML — está na resposta da rota.

Isso foi verificado empiricamente, não deduzido: aplicada a tag no _document e buildado o app, ela ficou como o primeiro <script> do <head>, com todos os chunks do Next seguindo com defer, e um teste de browser confirmou o env disponível antes do _app em / (estática), /login/aluno (estática) e /login (SSR).

Alternativas descartadas

Alternativa Por que não
disableNextScript (manter a lib) Não cobre as 15 páginas estáticas; mantém a lib abandonada e o Next 14 aninhado
next-dynenv@4.x (fork mantido) Exige React 19; o app está em React 18.2.0
publicRuntimeConfig + next/config Mecanismo oficial do Pages Router, mas exige tirar todas as páginas de ASO — mesma falha e ainda perde as páginas estáticas de marketing
Placeholder + substituição no boot do container Zero custo em runtime, mas muta o build depois do upload de sourcemaps do Sentry (deleteSourceMapsAfterUpload: true), dessincronizando stack traces
Build arg NEXT_PUBLIC_API_URL (imagem por ambiente) Bloqueado pelo pipeline: deploy-staging.yml e deploy-production.yml disparam na mesma tag v* e empurram para o mesmo tag do ECR — staging e production rodam hoje o mesmo trgclub-frontend:v0.1.12. Além disso .infra/build-args é estático (um valor só) e o Dockerfile do commons não declara ARG NEXT_PUBLIC_*, então o arg seria descartado em silêncio. Viabilizar exige PR em commons, pipelines e trgclub, e torna a imagem específica por ambiente
Roteamento same-origin no ingress (/api/v1 → backend-api:80) Tecnicamente o mais limpo — nada de env no browser — e verificado viável (backend-api:80 existe, config.hosts não restringe, force_ssl satisfeito pelo X-Forwarded-Proto). Descartado por legibilidade: a URL da API sumiria do código e passaria a morar num YAML de infra, e o dev local precisaria de um proxy próprio para reproduzir o cluster. Também faria o cookie do next-auth (~3,7 KB) viajar em toda chamada de API

A convenção NEXT_PUBLIC_ sai

Com a lib fora, o prefixo perde a razão de existir. Ele servia para dizer à next-runtime-env o que podia ir ao browser; agora quem decide isso é uma allowlist explícita na rota, hoje com uma única entrada: API_URL. Nada fora dela é serializado, então a garantia de não vazamento fica mais forte e mais legível do que a baseada em prefixo.

Consequência: run/runtime deixa de precisar de export NEXT_PUBLIC_API_URL="${API_URL}" — a rota lê process.env.API_URL direto do pod. A linha sai junto.

Contrato de falha — explícito nos dois lados

Sem isto, o desenho reproduz o bug atual: /api/env fora do ar, secret vazio ou resposta inválida levariam Env.get a devolver undefined, o axios voltaria à origem do frontend e o sintoma seria indistinguível do 404 de hoje — com build, lint e testes verdes.

Onde Comportamento exigido
pages/api/env Se API_URL não estiver no process.env, responder 500 com mensagem explícita. Nunca serializar {}
Env no browser Ausência de window.__ENV lança erro, em vez de cair para process.env
fetch.ts Usa Env.getOrThrow("API_URL") — a base da API é obrigatória, não opcional
Teste “client sem __ENV” Exige throw, não undefined

Normalização da base URL

O secret é .../api/v1 sem barra final, mas o código mistura as duas formas — e o mesmo arquivo usa ambas (NextAPIAuth linhas 42 e 171):

Forma Consumidores Resultado com o secret atual
${API_URL}path NextAPIAuth:171 (phone auth), pages/api/cadastro/initial-signup.js, pages/login/cliente/invite.tsx, src/utils/user.ts (avatar) /api/v1phone_authentication/... — inválido
${API_URL}/path NextAPIAuth:42, NextAPIAuth:104, pages/app/video/[id].tsx correto

.env.example tem barra final, então local funciona e ninguém percebeu. Os consumidores server-side já leem o secret em runtime hoje — ou seja, phone auth e cadastro inicial já estão quebrados em staging, independente do 404. Consertar o env sem consertar isto só troca um erro por outro.

Decisão: a base canônica é sem barra final, normalizada uma vez em Env, e o join passa a ser responsabilidade da instância axios / de um helper único. As quatro concatenações sem barra são corrigidas neste mesmo PR.

Trade-off aceito

Uma requisição same-origin bloqueante por page load completo. O tamanho da resposta é irrelevante: o parser para no <head> até a rota responder, então a latência dela entra praticamente 1:1 antes da aplicação — medido com atraso injetado de 750 ms, o _app só avaliou 700 ms depois. Navegação client-side não paga nada.

Por isso o custo precisa ser medido, não presumido: a implementação deve registrar a latência real de /api/env no build de produção local e declará-la no PR. Acima de ~50 ms o desenho volta à mesa.

Cache não é risco na topologia atual — DNS Terraform com proxied = false, staging resolve direto no ELB, sem annotation de proxy cache no ingress, e o Next preserva o Cache-Control: no-store.

Changes

Arquivo Mudança
modules/frontend/pages/api/env.ts Novo. Handler que responde window.__ENV = {...} como application/javascript, Cache-Control: no-store, montado por requisição a partir da allowlist ["API_URL"] lida do process.env
modules/frontend/pages/_document.tsx Troca <PublicEnvScript /> por <script src="/api/env" /> no <Head>; remove o import da lib. Requer /* eslint-disable-next-line @next/next/no-sync-scripts */ — sem isso o npm run build falha; a exceção é intencional e é justamente o comportamento síncrono que o desenho depende, protegido pelo teste de ordem
modules/frontend/src/infra/env/index.ts Remove next-runtime-env e o prefixo NEXT_PUBLIC_; server lê process.env[key], client lê window.__ENV[key]. Assinatura de Env.get/Env.getOrThrow inalterada
modules/frontend/run/runtime Remove export NEXT_PUBLIC_API_URL="${API_URL}", agora sem uso
modules/frontend/src/infra/env/index.test.ts Cobre server, client com __ENV, e client sem __ENV
modules/frontend/pages/api/env.test.ts Novo. Cobre content-type, no-store, allowlist e ausência da variável
modules/frontend/package.json / package-lock.json Remove a dependência next-runtime-env
modules/frontend/.env.example e .env.example da raiz Removem NEXT_PUBLIC_API_URL; API_URL passa a ser declarada sem barra final, igual ao secret
modules/frontend/src/services/fetch/fetch.ts Env.getOrThrow("API_URL"); a instância axios passa a ser a única a montar URL
NextAPIAuth/index.js:171, pages/api/cadastro/initial-signup.js, pages/login/cliente/invite.tsx, src/utils/user.ts Corrigem a concatenação sem barra

middleware.ts casa apenas /app/:path* e /cadastro/:path*, então a rota não é interceptada — nenhuma mudança necessária ali. Não há outro consumidor de NEXT_PUBLIC_API_URL no Dockerfile do commons, nos workflows, no next.config.js ou nos manifests além do run/runtime; o secret continua expondo API_URL.

How to verify

Automatizado

  • npm test — suíte verde, incluindo os testes novos de Env e da rota. Baseline antes da mudança: 36 suites, 219 testes, 1 skipped, 0 falhas.
  • Teste de ordem sobre .next/server/pages/index.html. O alvo do CI é o Docker test, e run/install roda npm run build antes do Jest, então o arquivo existe no job. Checar substring não basta — passaria se alguém adicionasse async/defer ou movesse a tag. O teste precisa parsear o HTML e exigir três coisas: a tag existe, é síncrona (sem async nem defer), e aparece antes do primeiro chunk do Next.
  • Teste de falha ruidosa: rota sem API_URL responde 500; Env no client sem window.__ENV lança.

Local, reproduzindo o k8s (build sem a URL, run com ela)

bash env -u API_URL -u NEXT_PUBLIC_API_URL npm run build kill -9 $(lsof -ti :3999 -sTCP:LISTEN) 2>/dev/null API_URL="https://beta.staging.api.trgclub.com/api/v1" WEB_PORT=3999 ./run/runtime

Conferir window.__ENV nas três formas de página, e sempre validar o buildId do HTML contra .next/BUILD_ID antes de concluir — servidor antigo preso na porta já produziu conclusão errada nesta investigação:

Página Tipo Esperado
/ estática window.__ENV.API_URL com o valor de runtime
/login/aluno estática idem
/login SSR idem
  • grep -r "staging.api.trgclub" .next/static → zero hits (nada inlinado).
  • Medir e registrar no PR a latência real de /api/env no build de produção local.
  • Subir sem API_URL e confirmar que a falha é ruidosa e imediata, não um 404 tardio.

Staging — só após v0.1.13: login em beta.staging.trgclub.com e confirmar na aba Network que as chamadas saem para beta.staging.api.trgclub.com/api/v1/..., sem 404.

Documentation

  • Atualizar .project/docs/reference/frontend/frontend_runtime_api_url.md: substituir o fluxo baseado em next-runtime-env pelo mecanismo da rota, e registrar que qualquer solução que grave o env no HTML em tempo de render quebra em página estaticamente otimizada.
  • Criar .project/docs/learnings/frontend_app_router_lib_in_pages_router.md: uma lib de App Router adotada no Pages Router falhou em silêncio porque a detecção de browser dela dependia do próprio valor que ela não conseguia injetar — e testes unitários, lint e build passaram verdes sem tocar no caminho quebrado.
  • Registrar no learning também o bug de concatenação: um contrato de URL ambíguo (com ou sem barra final) sobreviveu porque .env.example e o secret divergiam.
  • Atualizar o índice .project/docs/README.md com este spec e os docs acima.

Crítica

Este spec foi pressionado por um agente independente antes da implementação. O review está em frontend-runtime-env-spec-critique e derrubou três coisas na versão anterior: a mudança não buildava (regra no-sync-scripts), o contrato de falha silenciosa continuava aberto, e o bug de concatenação de URL não tinha sido visto. A premissa de ordem de execução, que era a mais frágil, foi confirmada empiricamente.