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.__ENVnunca é emitido, entãoEnv.get("API_URL")devolveundefinedno 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-envdo projeto, junto com onext@14aninhado 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
undefinedsilencioso — 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}pathe${API_URL}/pathque 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/v1no secret). - O fix de
proxy-buffer-sizedo 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 deprocess.env, e nenhuma existe no secret de staging — hoje todas resolvemundefinedem 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 <Head><br/><script src='/api/env'>"] -->|"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 deEnve 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 Dockertest, erun/installrodanpm run buildantes do Jest, então o arquivo existe no job. Checar substring não basta — passaria se alguém adicionasseasync/deferou movesse a tag. O teste precisa parsear o HTML e exigir três coisas: a tag existe, é síncrona (semasyncnemdefer), e aparece antes do primeiro chunk do Next. - Teste de falha ruidosa: rota sem
API_URLresponde 500;Envno client semwindow.__ENVlanç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/envno build de produção local. - Subir sem
API_URLe 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 emnext-runtime-envpelo 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.examplee o secret divergiam. - Atualizar o índice
.project/docs/README.mdcom 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.