Revisão + Projeto Final: Produção Real

[128] Revisão + Projeto Final: Produção Real

Deploy, segurança e performance só existem depois que a aplicação existe, e são eles que decidem se ela sobrevive ao contato com usuários. O projeto reúne os três num monorepo com pipeline que testa antes de publicar, headers de segurança, controle por dono, cache, índices e monitoramento de métricas reais.
Javascript

28 min de leitura

Chegamos ao fim de mais um módulo. Em três artigos cobrimos deploy, segurança e performance — os três pilares que separam um projeto que "funciona no meu computador" de uma aplicação profissional em produção.

Este artigo final do módulo tem dois objetivos. Primeiro, consolidar os conceitos com uma revisão estruturada. Segundo, integrar tudo em um projeto completo que representa o estado da arte de uma aplicação Node.js + React em produção: pipeline de CI/CD, segurança em camadas, performance monitorada e deploy automatizado.

O que vimos

Deploy

Deploy é o processo de levar código do computador do desenvolvedor para um servidor acessível na internet. O ponto central é que front-end e back-end têm naturezas diferentes e precisam de estratégias diferentes.

O front-end React é compilado em arquivos estáticos que qualquer servidor pode entregar. A Vercel e a Netlify detectam automaticamente projetos Vite, executam o build e servem os arquivos em uma CDN global. A única configuração necessária é o vercel.json ou netlify.toml para garantir que todas as rotas retornem index.html — fundamental para SPAs com React Router.

O back-end Node.js é um processo que precisa rodar continuamente em um servidor. O Railway faz isso de forma simples: conecta ao GitHub, detecta o projeto Node, e executa npm start. O banco de dados vive no MongoDB Atlas, que oferece um cluster gratuito suficiente para desenvolvimento e projetos pequenos.

O CI/CD com GitHub Actions fecha o ciclo: cada push para main dispara automaticamente os testes e, se passarem, o deploy — eliminando deploys manuais e garantindo que código com problemas nunca chegue à produção.

Fluxo completo de um deploy profissional:

git push origin main
       ↓
GitHub Actions: npm ci → npm run quality → npm test
       ↓ (somente se tudo passar)
Railway: npm run build → npm start
MongoDB Atlas: disponível 24/7
       ↓
Vercel: detecta push → npm run build → CDN global
       ↓
Usuário acessa https://meuapp.com.br

Segurança

Segurança é uma mentalidade, não uma checklist. O princípio fundamental é tratar toda entrada de dados como não confiável e defender em múltiplas camadas — se uma falhar, as outras ainda protegem.

As vulnerabilidades mais comuns e suas defesas:

Injeção NoSQL    → validar tipos, nunca concatenar queries
XSS              → React escapa por padrão; DOMPurify quando necessário
Auth fraca       → bcrypt fator 12, JWT com expiração, timing-safe compare
Força bruta      → rate limiting em /login (5 req/15min)
CORS aberto      → restringir às origens conhecidas em produção
Dados expostos   → select: false, Omit<T, 'senha'>, projeção no MongoDB
Deps vulneráveis → npm audit no CI/CD, atualizações regulares
Headers inseguros → Helmet com CSP configurado

Performance

Performance se mede, não se acha. O Lighthouse dá uma baseline, o DevTools Profiler localiza o problema, e a intervenção cirúrgica resolve com mínimo de complexidade adicionada.

Front-end — os maiores ganhos:
  Bundle size    → code splitting, lazy loading, manualChunks
  Re-renders     → React.memo + useCallback para listas com callbacks
  Listas longas  → virtualização com TanStack Virtual
  Imagens        → loading="lazy", fetchPriority="high" para LCP

Back-end — os maiores ganhos:
  Queries lentas → índices compostos (IXSCAN vs COLLSCAN)
  Dados em excesso → .lean() + .select() nas queries de leitura
  Round-trips    → Promise.all() para queries independentes
  Cálculos repetidos → cache em memória com TTL
  Banda de rede  → compressão gzip/brotli

O projeto final — aplicação em estado de produção

Vamos construir a versão de produção da SPA de gestão, adicionando tudo que aprendemos no Módulo 7 sobre a base do Módulo 6. O foco é mostrar como as camadas se encaixam — não reescrever o que já existe, mas elevar a qualidade.

Estrutura final do projeto

spa-gestao/
├── .github/
│   └── workflows/
│       ├── ci.yml          ← testes e qualidade em PRs
│       └── deploy.yml      ← deploy em push para main
├── apps/
│   ├── api/                ← back-end Node.js
│   │   ├── src/
│   │   │   ├── config/
│   │   │   │   ├── index.js
│   │   │   │   └── database.js
│   │   │   ├── middlewares/
│   │   │   │   ├── auth.js
│   │   │   │   ├── erros.js
│   │   │   │   ├── validar.js
│   │   │   │   ├── performance.js
│   │   │   │   └── rateLimiter.js
│   │   │   ├── models/
│   │   │   │   ├── Usuario.js
│   │   │   │   ├── Produto.js
│   │   │   │   └── Tarefa.js
│   │   │   ├── routes/
│   │   │   │   ├── auth.js
│   │   │   │   ├── produtos.js
│   │   │   │   └── tarefas.js
│   │   │   └── index.js
│   │   ├── tests/
│   │   ├── .env.example
│   │   └── package.json
│   └── web/                ← front-end React
│       ├── src/
│       │   ├── components/
│       │   ├── hooks/
│       │   ├── pages/
│       │   ├── services/
│       │   ├── stores/
│       │   └── utils/
│       │       └── webVitals.js
│       ├── public/
│       ├── vercel.json
│       ├── vite.config.js
│       └── package.json
└── README.md

Back-end — camadas de produção integradas

Vamos construir o index.js da API com todas as camadas do Módulo 7 integradas de forma coesa. A ordem dos middlewares importa — cada um tem um papel específico no pipeline de requisição.

// apps/api/src/index.js
// Cada middleware tem um propósito claro e uma posição específica na cadeia.
// A ordem não é arbitrária: segurança vem antes de lógica,
// compressão antes de resposta, erros depois de tudo.

require('dotenv').config();

const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const config = require('./config');
const { conectar } = require('./config/database');
const { monitorarPerformance } = require('./middlewares/performance');
const { limiteGeral, limiteAuth } = require('./middlewares/rateLimiter');

const app = express();

// ── 1. Segurança — headers HTTP de proteção ──────────
// Helmet deve vir antes de qualquer rota para garantir
// que todas as respostas tenham os headers corretos
app.use(helmet());
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      connectSrc: ["'self'", config.frontendUrl],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
      objectSrc: ["'none'"],
    },
  })
);

// ── 2. CORS — controle de origens permitidas ─────────
const origensPermitidas = [
  config.frontendUrl,
  ...(config.eDev ? ['http://localhost:5173', 'http://localhost:4173'] : []),
].filter(Boolean); // remove undefined se FRONTEND_URL não estiver configurada

app.use(
  cors({
    origin: (origin, callback) => {
      // Permite requisições sem origin (Postman, curl, apps mobile)
      if (!origin || origensPermitidas.includes(origin)) {
        return callback(null, true);
      }
      callback(new Error(`Origem bloqueada pelo CORS: ${origin}`));
    },
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    credentials: true,
    maxAge: 86400,
  })
);

// ── 3. Compressão — reduz tráfego de rede ───────────
// Vem antes das rotas para comprimir todas as respostas
app.use(compression({ threshold: 1024, level: 6 }));

// ── 4. Rate limiting global ──────────────────────────
app.use(limiteGeral);

// ── 5. Parse do corpo da requisição ─────────────────
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));

// ── 6. Monitoramento de performance ─────────────────
// Loga requisições lentas (> 500ms) e adiciona Server-Timing em dev
app.use(monitorarPerformance);

// ── 7. Health check — sem autenticação ──────────────
// Primeiro endpoint real — antes das rotas protegidas
// Plataformas de hospedagem (Railway, Fly.io) usam este endpoint
// para verificar se a aplicação está viva
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    versao: process.env.npm_package_version || '1.0.0',
    ambiente: config.ambiente,
    uptime: `${Math.floor(process.uptime())}s`,
    timestamp: new Date().toISOString(),
    // Informações de memória úteis para detectar memory leaks
    memoria: {
      usada: `${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`,
      total: `${Math.round(process.memoryUsage().heapTotal / 1024 / 1024)}MB`,
    },
  });
});

// ── 8. Rotas da API ──────────────────────────────────
// Rate limiting específico aplicado apenas nas rotas de autenticação
app.use('/auth', limiteAuth, require('./routes/auth'));
app.use('/produtos', require('./routes/produtos'));
app.use('/tarefas', require('./routes/tarefas'));

// ── 9. Tratamento de erros — sempre por último ───────
// O handler de 404 captura rotas não definidas
// O handler de erros captura todos os erros lançados nas rotas
const { naoEncontrado, tratadorDeErros } = require('./middlewares/erros');
app.use(naoEncontrado);
app.use(tratadorDeErros);

// ── Inicialização ────────────────────────────────────
async function iniciar() {
  try {
    await conectar();
    const servidor = app.listen(config.porta, () => {
      console.info(
        `[Server] ✅ Rodando na porta ${config.porta} (${config.ambiente})`
      );
    });

    // Encerramento gracioso: aguarda requisições em andamento antes de parar.
    // O Railway envia SIGTERM antes de reiniciar o container.
    // Sem isso, requisições em processamento seriam abortadas abruptamente.
    process.on('SIGTERM', () => {
      console.info('[Server] SIGTERM recebido. Encerrando graciosamente...');
      servidor.close(() => {
        console.info('[Server] Conexões fechadas. Processo encerrado.');
        process.exit(0);
      });
    });
  } catch (erro) {
    console.error('[Server] ❌ Falha ao iniciar:', erro.message);
    process.exit(1);
  }
}

iniciar();
module.exports = app; // exporta para os testes

Agora os middlewares de segurança e rate limiting centralizados:

// apps/api/src/middlewares/rateLimiter.js
// Separar em arquivo próprio facilita ajustar os limites sem tocar no index.js
const rateLimit = require('express-rate-limit');

// Mensagem padrão — retorna JSON consistente com o restante da API
function mensagemPadrao(limite, janela) {
  return {
    erro: `Muitas requisições. Limite: ${limite} por ${janela} minutos.`,
    status: 429,
  };
}

// Limite geral: 100 requisições por 15 minutos
// Protege contra bots e scrapers indiscriminados
const limiteGeral = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: mensagemPadrao(100, 15),
});

// Limite de autenticação: 5 tentativas por 15 minutos
// Protege contra ataques de força bruta em senhas
// skipSuccessfulRequests: logins bem-sucedidos não contam — só falhas
const limiteAuth = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true,
  standardHeaders: true,
  legacyHeaders: false,
  message: mensagemPadrao(5, 15),
});

module.exports = { limiteGeral, limiteAuth };
// apps/api/src/middlewares/erros.js
// Tratamento centralizado de erros — captura tudo que chegar aqui
// e transforma em respostas JSON consistentes

const config = require('../config');

// Captura rotas não definidas — deve vir após todas as rotas
function naoEncontrado(req, res) {
  res.status(404).json({
    erro: `Rota não encontrada: ${req.method} ${req.path}`,
    status: 404,
  });
}

// Handler global de erros — Express identifica pelo número de parâmetros (4)
// Deve ser o ÚLTIMO middleware registrado no app
function tratadorDeErros(erro, req, res, next) {
  // Log completo em desenvolvimento, mínimo em produção
  // (nunca exponha stack traces para o cliente em produção)
  if (config.eDev) {
    console.error('[Erro]', erro);
  } else {
    console.error(`[Erro] ${erro.message}`);
  }

  // Erros de validação do Mongoose — campos obrigatórios faltando, etc.
  if (erro.name === 'ValidationError') {
    const detalhes = Object.values(erro.errors).map((e) => e.message);
    return res.status(422).json({ erro: 'Dados inválidos.', detalhes, status: 422 });
  }

  // ID MongoDB com formato inválido (ex: 'abc' no lugar de ObjectId)
  if (erro.name === 'CastError') {
    return res.status(400).json({ erro: 'ID inválido.', status: 400 });
  }

  // Violação de campo único (ex: email duplicado)
  if (erro.code === 11000) {
    const campo = Object.keys(erro.keyValue)[0];
    return res.status(409).json({
      erro: `${campo} já está em uso.`,
      status: 409,
    });
  }

  // Token JWT expirado — o front-end deve redirecionar para /login
  if (erro.name === 'TokenExpiredError') {
    return res.status(401).json({ erro: 'Sessão expirada. Faça login novamente.', status: 401 });
  }

  // Token JWT malformado ou assinatura inválida
  if (erro.name === 'JsonWebTokenError') {
    return res.status(401).json({ erro: 'Token inválido.', status: 401 });
  }

  // Qualquer outro erro — resposta genérica em produção para não vazar detalhes
  const status = erro.statusCode || erro.status || 500;
  res.status(status).json({
    erro: config.eProd ? 'Erro interno do servidor.' : erro.message,
    status,
    // Stack trace apenas em desenvolvimento
    ...(config.eDev && { stack: erro.stack }),
  });
}

module.exports = { naoEncontrado, tratadorDeErros };

Rota com todas as camadas integradas

Vamos ver como uma rota completa se parece com autenticação, validação, cache e índices trabalhando juntos.

// apps/api/src/routes/tarefas.js
// Esta rota demonstra as camadas em ação:
// autenticação → validação → cache → query otimizada → resposta

const express = require('express');
const mongoose = require('mongoose');
const { z } = require('zod');
const NodeCache = require('node-cache');
const { autenticar } = require('../middlewares/auth');
const { validar } = require('../middlewares/validar');
const Tarefa = require('../models/Tarefa');

const router = express.Router();

// Cache de estatísticas — TTL de 2 minutos
// Estatísticas são caras de calcular mas não precisam ser em tempo real
const cacheStats = new NodeCache({ stdTTL: 120 });

// Schemas de validação — Zod garante tipos e formatos corretos
const schemaCriar = z.object({
  titulo: z.string().min(2).max(200).trim(),
  descricao: z.string().max(2000).trim().optional(),
  prioridade: z.enum(['baixa', 'media', 'alta']).default('media'),
  prazo: z.string().datetime().optional(),
  tags: z.array(z.string().trim()).max(10).optional(),
});

const schemaAtualizar = schemaCriar.partial(); // todos os campos opcionais

// ── GET /tarefas — listagem otimizada ────────────────
router.get('/', autenticar, async (req, res, next) => {
  try {
    const {
      status,
      prioridade,
      busca,
      pagina = '1',
      por_pagina = '10',
      ordenar = '-criadoEm',
    } = req.query;

    // Monta filtros — sempre incluindo o usuário autenticado
    // Isso garante isolamento de dados entre usuários
    const filtros = { usuario: req.usuario._id };
    if (status) filtros.status = status;
    if (prioridade) filtros.prioridade = prioridade;
    if (busca) filtros.$text = { $search: busca };

    const paginaNum = Math.max(1, parseInt(pagina, 10));
    const porPaginaNum = Math.min(50, Math.max(1, parseInt(por_pagina, 10)));
    const skip = (paginaNum - 1) * porPaginaNum;

    // Promise.all executa count e find em paralelo — economiza uma ida ao banco
    const [tarefas, total] = await Promise.all([
      Tarefa.find(filtros)
        .sort(ordenar)
        .skip(skip)
        .limit(porPaginaNum)
        // .select() reduz dados transferidos do banco para a API
        .select('titulo status prioridade prazo tags criadoEm')
        // .lean() retorna objetos JS simples — mais rápido que documentos Mongoose
        .lean(),
      Tarefa.countDocuments(filtros),
    ]);

    res.json({
      dados: tarefas,
      paginacao: {
        total,
        pagina: paginaNum,
        por_pagina: porPaginaNum,
        total_paginas: Math.ceil(total / porPaginaNum),
      },
    });
  } catch (erro) {
    next(erro);
  }
});

// ── GET /tarefas/estatisticas — com cache ────────────
router.get('/estatisticas', autenticar, async (req, res, next) => {
  try {
    const usuarioId = req.usuario._id.toString();
    const chaveCache = `stats:${usuarioId}`;

    // Tenta o cache primeiro — retorna instantaneamente se houver hit
    const emCache = cacheStats.get(chaveCache);
    if (emCache) return res.json(emCache);

    // Cache miss — executa a aggregation (operação custosa)
    const stats = await Tarefa.aggregate([
      // $match usa o índice { usuario: 1, status: 1 } — IXSCAN eficiente
      { $match: { usuario: new mongoose.Types.ObjectId(usuarioId) } },
      {
        $group: {
          _id: '$status',
          total: { $sum: 1 },
          comPrazo: {
            $sum: { $cond: [{ $ifNull: ['$prazo', false] }, 1, 0] },
          },
        },
      },
      {
        $project: {
          status: '$_id',
          total: 1,
          comPrazo: 1,
          _id: 0,
        },
      },
    ]);

    const resposta = { categorias: stats, geradoEm: new Date().toISOString() };

    // Armazena no cache — próximas requisições nos próximos 2 min são instantâneas
    cacheStats.set(chaveCache, resposta);
    res.json(resposta);
  } catch (erro) {
    next(erro);
  }
});

// ── POST /tarefas — criação com validação ────────────
router.post('/', autenticar, validar(schemaCriar), async (req, res, next) => {
  try {
    // req.body já foi validado e sanitizado pelo middleware validar()
    const tarefa = await Tarefa.create({
      ...req.body,
      usuario: req.usuario._id, // sempre vincula ao usuário autenticado
    });

    // Invalida o cache de estatísticas — dados mudaram
    cacheStats.del(`stats:${req.usuario._id}`);

    res.status(201).json(tarefa);
  } catch (erro) {
    next(erro);
  }
});

// ── PUT /tarefas/:id — atualização com ownership check ─
router.put('/:id', autenticar, validar(schemaAtualizar), async (req, res, next) => {
  try {
    const { id } = req.params;

    if (!mongoose.isValidObjectId(id)) {
      return res.status(400).json({ erro: 'ID inválido.', status: 400 });
    }

    // findOne com filtro de usuário — garante que só o dono pode editar
    // Retorna 404 (não 403) para não confirmar que o recurso existe
    const tarefa = await Tarefa.findOneAndUpdate(
      { _id: id, usuario: req.usuario._id }, // ownership check implícito
      { $set: req.body },
      { new: true, runValidators: true }
    );

    if (!tarefa) {
      return res.status(404).json({ erro: 'Tarefa não encontrada.', status: 404 });
    }

    cacheStats.del(`stats:${req.usuario._id}`);
    res.json(tarefa);
  } catch (erro) {
    next(erro);
  }
});

// ── DELETE /tarefas/:id ──────────────────────────────
router.delete('/:id', autenticar, async (req, res, next) => {
  try {
    const { id } = req.params;

    if (!mongoose.isValidObjectId(id)) {
      return res.status(400).json({ erro: 'ID inválido.', status: 400 });
    }

    const tarefa = await Tarefa.findOneAndDelete({
      _id: id,
      usuario: req.usuario._id, // ownership check
    });

    if (!tarefa) {
      return res.status(404).json({ erro: 'Tarefa não encontrada.', status: 404 });
    }

    cacheStats.del(`stats:${req.usuario._id}`);
    res.json({ mensagem: `Tarefa "${tarefa.titulo}" removida com sucesso.` });
  } catch (erro) {
    next(erro);
  }
});

module.exports = router;

Front-end — performance e monitoramento integrados

// apps/web/vite.config.js
// Configuração de produção com análise de bundle e code splitting otimizado
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig(({ mode }) => ({
  plugins: [
    react(),
    // Visualizador apenas quando analisando o bundle (npm run build -- --mode analyze)
    mode === 'analyze' &&
      visualizer({
        open: true,
        gzipSize: true,
        brotliSize: true,
        filename: 'bundle-analysis.html',
      }),
  ].filter(Boolean),

  build: {
    // Avisa se algum chunk ultrapassar 500KB antes de gzip
    chunkSizeWarningLimit: 500,

    rollupOptions: {
      output: {
        // Nomeia os chunks com o nome do módulo para facilitar debugging
        // e melhorar cache — vendor-react só muda quando React atualiza
        manualChunks: {
          'vendor-react': ['react', 'react-dom'],
          'vendor-router': ['react-router-dom'],
          'vendor-query': ['@tanstack/react-query'],
          'vendor-store': ['zustand'],
        },
      },
    },
  },

  // Proxy em desenvolvimento — evita problemas de CORS ao desenvolver
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, ''),
      },
    },
  },
}));
// apps/web/src/utils/webVitals.js
// Monitora e reporta Core Web Vitals em produção
// Dados reais de usuários são mais valiosos que dados de laboratório
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

// Classifica a métrica como "good", "needs-improvement" ou "poor"
// com base nos limites definidos pelo Google
function classificarMetrica(name, value) {
  const limites = {
    LCP:  { good: 2500,  poor: 4000  },
    INP:  { good: 200,   poor: 500   },
    CLS:  { good: 0.1,   poor: 0.25  },
    FCP:  { good: 1800,  poor: 3000  },
    TTFB: { good: 800,   poor: 1800  },
  };

  const limite = limites[name];
  if (!limite) return 'unknown';
  if (value <= limite.good) return 'good';
  if (value <= limite.poor) return 'needs-improvement';
  return 'poor';
}

function reportar(metrica) {
  const classificacao = classificarMetrica(metrica.name, metrica.value);
  const valor = metrica.name === 'CLS'
    ? metrica.value.toFixed(3)      // CLS é adimensional
    : `${Math.round(metrica.value)}ms`; // as demais são em ms

  // Log colorido para facilitar debugging em produção
  const cor = classificacao === 'good' ? '✅' : classificacao === 'needs-improvement' ? '⚠️' : '❌';
  console.log(`[Web Vitals] ${cor} ${metrica.name}: ${valor} (${classificacao})`);

  // Em produção, envie para o seu serviço de analytics
  // Exemplo com fetch para endpoint próprio de métricas:
  if (import.meta.env.PROD && import.meta.env.VITE_METRICS_URL) {
    navigator.sendBeacon(
      import.meta.env.VITE_METRICS_URL,
      JSON.stringify({
        name: metrica.name,
        value: metrica.value,
        classificacao,
        id: metrica.id,
        url: window.location.pathname,
        timestamp: Date.now(),
      })
    );
  }
}

export function iniciarMonitoramento() {
  onCLS(reportar);   // Cumulative Layout Shift
  onINP(reportar);   // Interaction to Next Paint
  onLCP(reportar);   // Largest Contentful Paint
  onFCP(reportar);   // First Contentful Paint
  onTTFB(reportar);  // Time to First Byte (velocidade do servidor)
}
// apps/web/src/main.jsx — integrando o monitoramento
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
import { iniciarMonitoramento } from './utils/webVitals';
import './index.css';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      gcTime: 1000 * 60 * 10,
      retry: 2,
      refetchOnWindowFocus: true,
    },
    mutations: {
      // Em caso de erro de rede, tenta 1 vez antes de desistir
      retry: 1,
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
      {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
    </QueryClientProvider>
  </React.StrictMode>
);

// Inicia monitoramento de Web Vitals após o React montar
// requestIdleCallback garante que não impacta o tempo de carregamento inicial
if (import.meta.env.PROD) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(iniciarMonitoramento);
  } else {
    setTimeout(iniciarMonitoramento, 0);
  }
}

Pipeline de CI/CD completo

O pipeline é o coração do processo de entrega contínua. Ele garante que código com problemas nunca chegue à produção e que deploys bem-sucedidos aconteçam automaticamente.

# .github/workflows/ci.yml
# Roda em Pull Requests — verifica qualidade antes do merge
name: Integração Contínua

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  # ── Qualidade e Testes ─────────────────────────────
  verificar:
    runs-on: ubuntu-latest
    name: Qualidade e Testes

    # Serviço MongoDB temporário para os testes de integração
    # O GitHub Actions sobe um container MongoDB durante o job
    services:
      mongodb:
        image: mongo:7
        ports:
          - 27017:27017

    steps:
      - uses: actions/checkout@v4

      - name: Configurar Node.js 20
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          # Cache de node_modules — acelera builds subsequentes
          cache: 'npm'
          cache-dependency-path: 'apps/api/package-lock.json'

      - name: Instalar dependências da API
        working-directory: apps/api
        run: npm ci

      - name: Verificar formatação (Prettier)
        working-directory: apps/api
        run: npm run format:check

      - name: Verificar lint (ESLint)
        working-directory: apps/api
        run: npm run lint

      - name: Auditoria de segurança das dependências
        working-directory: apps/api
        # Falha apenas para vulnerabilidades de severidade alta ou crítica
        run: npm audit --audit-level=high

      - name: Executar testes com cobertura
        working-directory: apps/api
        run: npm run test:coverage
        env:
          NODE_ENV: test
          MONGODB_TEST_URL: mongodb://localhost:27017/gestao-test
          JWT_SECRET: chave-de-teste-apenas-para-ci-nao-use-em-prod

      # Upload do relatório de cobertura para acompanhamento histórico
      - name: Upload de cobertura
        uses: codecov/codecov-action@v4
        if: always() # roda mesmo se os testes falharem
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          directory: apps/api/coverage

  # ── Build do Front-end ─────────────────────────────
  build-frontend:
    runs-on: ubuntu-latest
    name: Build do Front-end

    steps:
      - uses: actions/checkout@v4

      - name: Configurar Node.js 20
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: 'apps/web/package-lock.json'

      - name: Instalar dependências do front-end
        working-directory: apps/web
        run: npm ci

      - name: Build de produção
        working-directory: apps/web
        run: npm run build
        env:
          # URL da API de produção — usada no build
          VITE_API_URL: ${{ secrets.VITE_API_URL }}

      # Guarda os artefatos de build para o job de deploy
      - name: Salvar artefatos de build
        uses: actions/upload-artifact@v4
        with:
          name: build-web
          path: apps/web/dist
          retention-days: 1
# .github/workflows/deploy.yml
# Roda apenas em push para main — faz deploy após CI passar
name: Deploy para Produção

on:
  push:
    branches: [main]

# Garante que apenas um deploy roda por vez
# Se dois commits chegam rápido, o segundo aguarda o primeiro terminar
concurrency:
  group: deploy-producao
  cancel-in-progress: false

jobs:
  # ── Deploy da API no Railway ───────────────────────
  deploy-api:
    runs-on: ubuntu-latest
    name: Deploy API → Railway

    # Só faz deploy se o CI passou (referencia o workflow de CI)
    needs: [] # adicione o job de CI aqui se estiver no mesmo arquivo

    steps:
      - uses: actions/checkout@v4

      - name: Deploy no Railway
        uses: railwayapp/railway-action@v1
        with:
          service: ${{ secrets.RAILWAY_SERVICE_ID }}
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}

      # Aguarda a API estar disponível antes de declarar sucesso
      - name: Verificar health check pós-deploy
        run: |
          echo "Aguardando API inicializar..."
          sleep 20
          # Tenta até 5 vezes com intervalo de 10 segundos
          for i in {1..5}; do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" 
              ${{ secrets.API_URL }}/health)
            if [ "$STATUS" = "200" ]; then
              echo "✅ API está saudável (HTTP $STATUS)"
              exit 0
            fi
            echo "⏳ Tentativa $i/5 — HTTP $STATUS. Aguardando..."
            sleep 10
          done
          echo "❌ API não respondeu após 5 tentativas"
          exit 1

  # ── Deploy do front-end na Vercel ─────────────────
  deploy-web:
    runs-on: ubuntu-latest
    name: Deploy Web → Vercel
    needs: [deploy-api] # aguarda a API estar disponível

    steps:
      - uses: actions/checkout@v4

      - name: Deploy na Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'
          working-directory: apps/web

O que este projeto demonstra

Vamos olhar para o caminho percorrido nos dois últimos módulos e ver como cada peça se encaixa na aplicação final.

Módulo 6 — construiu a aplicação:
  Introdução ao React        → componentes, props, estado, JSX
  React Hooks em profundidade → useRef, useContext, useReducer, useMemo, hooks customizados
  Zustand e React Query      → sessão e carrinho de um lado; cache, mutação e otimismo do outro
  React Router               → rotas, proteção por login e papel, lazy loading
  Projeto: SPA Completa      → as quatro peças integradas

Módulo 7 — levou para produção:
  Deploy                     → Vercel + Railway + MongoDB Atlas + CI/CD básico
  Segurança                  → Helmet, CORS, bcrypt, JWT, rate limiting, Zod, controle por dono
  Performance                → bundle, memo, virtualização, índices, cache, compressão
  Este projeto               → pipeline completo, monitoramento, camadas coesas

Deploy, segurança e performance são os três assuntos que só existem depois de a aplicação existir — e os que decidem se ela sobrevive ao contato com usuários de verdade. Têm em comum o fato de nunca ficarem resolvidos: cada dependência atualizada, cada rota nova e cada consulta acrescentada reabrem os três. É por isso que checklist e automação valem mais aqui do que em qualquer outra parte do trabalho.

Fontes e Referências

Exercícios

Exercício 1

A URL da API mudou. O valor foi atualizado no painel da Vercel e o serviço reiniciado. O site em produção continua chamando o endereço antigo. Por quê?

// src/services/api.js
const BASE_URL = import.meta.env.VITE_API_URL;

// no painel da Vercel:
// VITE_API_URL = https://api-nova.railway.app   (era api-antiga)
Ver resposta

✓ Resposta: Porque variável do Vite é resolvida no build, não em execução. O que foi publicado não lê VITE_API_URL quando o usuário abre a página: o valor antigo foi escrito dentro do arquivo JavaScript no momento em que o build rodou, como texto literal. Mudar a variável no painel só afeta o próximo build — e reiniciar não adianta, porque não há processo para reiniciar: o front-end é um punhado de arquivos estáticos numa CDN. A solução é redeploy, e na Vercel o caminho mais rápido é o "Redeploy" da última implantação, com o cache desativado. Essa é uma diferença de fundo entre front e back que confunde muita gente: no servidor, variável de ambiente é lida em execução; no cliente, ela é congelada na compilação. A mesma lógica explica por que trocar um segredo numa variável VITE_ não o torna secreto, e por que mudanças de configuração do front sempre custam um build. Quando a URL precisa mesmo variar sem rebuildar — em ambientes de homologação gerados dinamicamente, por exemplo —, o caminho é outro: servir um config.json junto dos estáticos e buscá-lo no boot da aplicação, ou deduzir a URL a partir do próprio domínio.

Exercício 2

Sem o .filter(Boolean), o build quebra com Cannot read properties of false. O que esse trecho está fazendo?

export default defineConfig(({ mode }) => ({
  plugins: [
    react(),
    mode === 'analyze' && visualizer({ open: true, gzipSize: true }),
  ].filter(Boolean),
}));
Ver resposta

✓ Resposta: É a inclusão condicional de um plugin. Quando o modo não é analyze, a expressão mode === 'analyze' && visualizer(...) resulta em false — e não em undefined nem em nada — porque o && devolve o primeiro operando falsy. O array de plugins fica então com um false no meio, e o Vite, ao percorrer a lista procurando propriedades como name e transform, tenta lê-las de um booleano. O .filter(Boolean) remove esse item antes que isso aconteça. É o mesmo comportamento do && visto no React, onde lista.length && … imprime um zero na tela: em ambos os casos o operador não devolve booleano, devolve um dos operandos. Vale notar a alternativa mais legível, que dispensa o truque: montar o array com espalhamento condicional, ...(mode === 'analyze' ? [visualizer(...)] : []) — assim nada falso chega a existir na lista. E vale a dica prática do visualizer: rodá-lo com gzipSize e brotliSize é o que importa, porque o tamanho bruto do bundle não é o que trafega; uma biblioteca de 300 KB que comprime para 40 KB pesa muito menos do que o número cru sugere.

Exercício 3

O cache de estatísticas é invalidado a cada escrita. Depois de escalar para duas instâncias, usuários passam a ver números desatualizados de forma intermitente. Por quê?

const cacheStats = new NodeCache({ stdTTL: 60 });

// na leitura
const cached = cacheStats.get(`stats:${req.usuario._id}`);
if (cached) return res.json(cached);

// na escrita
cacheStats.del(`stats:${req.usuario._id}`);
Ver resposta

✓ Resposta: Porque o cache vive na memória de um processo. Com duas instâncias atrás de um balanceador, existem dois caches independentes, e o del só apaga o da instância que atendeu a escrita. Se a leitura seguinte cair na outra, ela devolve o valor velho — e como o balanceamento é imprevisível, o usuário vê o número certo, atualiza a página, vê o errado, atualiza de novo e vê o certo. O sintoma intermitente é a assinatura do problema. Vale reparar que o defeito não existia com uma instância só, e apareceu numa mudança de infraestrutura em que ninguém tocou no código — é o mesmo caso do rate limit em memória visto no artigo de Express. A saída é mover o estado compartilhado para fora dos processos, tipicamente Redis, onde tanto o valor quanto a invalidação são vistos por todos. E há um cuidado que costuma ser esquecido junto: com PM2 em modo cluster ou com múltiplos workers, o problema acontece na mesma máquina, o que engana ainda mais. A regra prática: cache em memória só é correto quando há exatamente um processo — e "por enquanto só tem um" não costuma durar.

Exercício 4

O repositório tem apps/web e apps/api. Uma correção de uma linha no README dispara build, testes e deploy das duas aplicações. Como evitar?

on:
  push:
    branches: [main]

jobs:
  web: # build, testes e deploy do front
  api: # build, testes e deploy do back
Ver resposta

✓ Resposta: Filtrando por caminho. O GitHub Actions aceita paths no gatilho, o que permite separar os workflows: um que só dispara com paths: ['apps/web/**'] e outro com apps/api/**. Num monorepo que cresce, isso deixa de ser economia de minutos e vira produtividade — ninguém espera dez minutos de pipeline para corrigir uma vírgula na documentação. Duas armadilhas práticas acompanham essa configuração. A primeira: os arquivos compartilhados precisam disparar os dois — o package-lock.json da raiz, uma pasta packages/ de código comum, o próprio arquivo de workflow —, senão uma mudança de dependência passa sem teste. A segunda é mais traiçoeira: se uma verificação estiver marcada como obrigatória na proteção da branch e o filtro impedir que ela rode, o Pull Request fica eternamente pendente, esperando um job que nunca vai existir. A saída padrão é um job que sempre roda e reporta sucesso quando não havia nada a fazer. E vale conhecer o caminho mais robusto para monorepos grandes, que é usar uma ferramenta como Turborepo ou Nx: em vez de filtrar por pasta, elas calculam o que de fato foi afetado pelo que mudou, e ainda aproveitam cache entre execuções.

Exercício 5

O deploy do back-end e o do front-end são independentes. A API ganhou um campo obrigatório novo e o front ainda não foi publicado. O que acontece entre um deploy e outro?

// API, versão nova: POST /tarefas agora exige "prioridade"
// Front, versão antiga: envia apenas { titulo, descricao }
Ver resposta

✓ Resposta: Todo mundo que estiver com a versão antiga da página aberta passa a receber erro ao criar tarefa — e essa janela é maior do que parece, porque quem já tem o site carregado continua com o JavaScript antigo até recarregar. O problema não é o deploy: é a mudança incompatível. A regra que evita a classe inteira é fazer alterações de API em duas etapas: primeiro o campo entra como opcional, com valor padrão no servidor; o front é publicado passando a enviá-lo; e só numa terceira etapa, quando ninguém mais manda sem ele, o campo vira obrigatório. Vale para acrescentar campo, renomear, mudar formato de resposta e remover endpoint — sempre no mesmo formato: adicione o novo, migre os clientes, remova o velho. Há um agravante específico de SPA que merece atenção: depois de um deploy, o navegador de quem está com a página aberta pode pedir um arquivo de JavaScript que não existe mais, porque o build novo gerou outros nomes com hash — o sintoma é uma tela branca ao navegar para uma rota com lazy, e a correção usual é detectar a falha de carregamento do módulo e sugerir recarregar a página. E, como ordem geral de publicação, o servidor vai primeiro: uma API que já aceita o formato novo e o antigo é compatível com as duas versões do front; o inverso não é.

Comentários

Mais em Javascript

NPM: gerenciando pacotes e dependências
NPM: gerenciando pacotes e dependências

Todo projeto Node começa com um package.json de quinze linhas e termina com um…

Padrões de Projeto em JavaScript
Padrões de Projeto em JavaScript

Dizer "isto é um Adapter" numa revisão economiza um parágrafo de explicação, e…

Projeto: API REST com autenticação JWT
Projeto: API REST com autenticação JWT

O Módulo 4 fecha juntando Node, Express, MongoDB e Mongoose numa API de…