Criando um servidor HTTP com Node.js puro

[111] Criando um servidor HTTP com Node.js puro

Um require de http, uma função de dois argumentos, e o servidor está de pé. O resto do artigo é o que vem depois: ler o corpo que chega em pedaços, rotear no if e na regex, servir arquivo estático sem abrir a máquina inteira, tratar EADDRINUSE e desligar sem cortar requisição em andamento.
Javascript

23 min de leitura

Antes de usar o Express — o framework que simplifica tudo — é fundamental entender o que acontece por baixo dos panos. Todo servidor web em Node.js, seja Express, Fastify ou qualquer outro, é construído sobre o módulo nativo http. Entender esse módulo faz você um desenvolvedor melhor, capaz de depurar problemas que frameworks escondem.

Neste artigo vamos construir um servidor HTTP do zero, entender o ciclo de requisição e resposta, e gradualmente adicionar funcionalidades até termos uma mini API funcional.

O protocolo HTTP em 60 segundos

Antes do código, o conceito:

CLIENTE (navegador/fetch)         SERVIDOR (Node.js)
─────────────────────────────────────────────────────

1. Cliente envia uma REQUISIÇÃO:
   GET /usuarios HTTP/1.1
   Host: localhost:3000
   Accept: application/json

2. Servidor processa e envia uma RESPOSTA:
   HTTP/1.1 200 OK
   Content-Type: application/json

   [{ "id": 1, "nome": "Ana" }]

Toda comunicação HTTP segue este padrão:
  Método + URL + Headers → Corpo (opcional)
  Status + Headers → Corpo (opcional)

O primeiro servidor

// servidor.js
const http = require("http");

const servidor = http.createServer((req, res) => {
  // req → IncomingMessage — a requisição que chegou
  // res → ServerResponse — a resposta que vamos enviar

  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.end("Olá, mundo!");
});

servidor.listen(3000, () => {
  console.log("🚀 Servidor rodando em http://localhost:3000");
});
node servidor.js
# Abra http://localhost:3000 no navegador

Três coisas aconteceram:

  1. createServer registrou uma função que será chamada a cada requisição
  2. listen(3000) fez o servidor começar a escutar conexões na porta 3000
  3. O callback é chamado quando o servidor está pronto

Inspecionando a requisição

O objeto req contém tudo sobre o que o cliente enviou:

const http = require("http");

const servidor = http.createServer((req, res) => {
  console.log("─".repeat(40));
  console.log("Método:", req.method);         // GET, POST, PUT, DELETE...
  console.log("URL:", req.url);               // /usuarios/1?ativo=true
  console.log("Headers:", req.headers);       // { host, accept, ... }
  console.log("HTTP Version:", req.httpVersion); // 1.1

  // Parseando a URL manualmente
  const url = new URL(req.url, `http://${req.headers.host}`);
  console.log("Pathname:", url.pathname);     // /usuarios/1
  console.log("Search:", url.search);         // ?ativo=true
  console.log("Params:", url.searchParams.get("ativo")); // "true"

  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("OK");
});

servidor.listen(3000, () => console.log("http://localhost:3000"));

Construindo a resposta

O objeto res controla tudo que enviamos de volta:

// Definindo status e headers
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.setHeader("X-Powered-By", "Node.js");

// Atalho — define status e múltiplos headers de uma vez
res.writeHead(201, {
  "Content-Type": "application/json",
  "Location": "/usuarios/42",
});

// Enviando o corpo — finaliza a resposta
res.end(JSON.stringify({ id: 42, nome: "Ana" }));

// Ou em partes (útil para streams)
res.write("parte 1");
res.write("parte 2");
res.end("parte final");

Lendo o corpo da requisição

Ao contrário da URL e dos headers, o corpo chega em pedaços (chunks) via stream:

function lerCorpo(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];

    req.on("data", (chunk) => {
      chunks.push(chunk);

      // Proteção contra corpos muito grandes (10MB)
      const totalBytes = chunks.reduce((acc, c) => acc + c.length, 0);
      if (totalBytes > 10 * 1024 * 1024) {
        reject(new Error("Corpo da requisição muito grande."));
      }
    });

    req.on("end", () => {
      const corpo = Buffer.concat(chunks).toString("utf-8");
      resolve(corpo);
    });

    req.on("error", reject);
  });
}

// Usando
const corpo = await lerCorpo(req);
const dados = JSON.parse(corpo);

Roteamento manual

O servidor precisa responder diferente dependendo da URL e do método:

const http = require("http");
const { URL } = require("url");

// Banco de dados em memória
let usuarios = [
  { id: 1, nome: "Ana Paula", email: "ana@email.com" },
  { id: 2, nome: "Carlos Silva", email: "carlos@email.com" },
];
let proximoId = 3;

// ── Utilitários ─────────────────────────────────

function lerCorpo(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    req.on("data", chunk => chunks.push(chunk));
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
    req.on("error", reject);
  });
}

function enviarJSON(res, status, dados) {
  res.writeHead(status, {
    "Content-Type": "application/json; charset=utf-8",
    "Access-Control-Allow-Origin": "*",
  });
  res.end(JSON.stringify(dados, null, 2));
}

function enviarErro(res, status, mensagem) {
  enviarJSON(res, status, { erro: mensagem, status });
}

function extrairId(pathname) {
  const partes = pathname.split("/").filter(Boolean);
  // /usuarios/42 → ["usuarios", "42"]
  const id = Number(partes[1]);
  return isNaN(id) ? null : id;
}

// ── Roteador ────────────────────────────────────

async function roteador(req, res) {
  const url = new URL(req.url, `http://${req.headers.host}`);
  const pathname = url.pathname;
  const metodo = req.method;

  console.log(`[${new Date().toLocaleTimeString("pt-BR")}] ${metodo} ${pathname}`);

  // CORS preflight
  if (metodo === "OPTIONS") {
    res.writeHead(204, {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type",
    });
    return res.end();
  }

  // GET /
  if (pathname === "/" && metodo === "GET") {
    return enviarJSON(res, 200, {
      mensagem: "API funcionando!",
      versao: "1.0.0",
      rotas: [
        "GET /usuarios",
        "GET /usuarios/:id",
        "POST /usuarios",
        "PUT /usuarios/:id",
        "DELETE /usuarios/:id",
      ],
    });
  }

  // GET /usuarios
  if (pathname === "/usuarios" && metodo === "GET") {
    const busca = url.searchParams.get("busca")?.toLowerCase();
    const lista = busca
      ? usuarios.filter(u =>
          u.nome.toLowerCase().includes(busca) ||
          u.email.toLowerCase().includes(busca)
        )
      : usuarios;

    return enviarJSON(res, 200, {
      dados: lista,
      total: lista.length,
    });
  }

  // GET /usuarios/:id
  if (pathname.match(/^\/usuarios\/\d+$/) && metodo === "GET") {
    const id = extrairId(pathname);
    const usuario = usuarios.find(u => u.id === id);

    if (!usuario) return enviarErro(res, 404, `Usuário ${id} não encontrado.`);
    return enviarJSON(res, 200, usuario);
  }

  // POST /usuarios
  if (pathname === "/usuarios" && metodo === "POST") {
    let corpo;
    try {
      const texto = await lerCorpo(req);
      corpo = JSON.parse(texto);
    } catch {
      return enviarErro(res, 400, "Corpo da requisição inválido. Envie JSON válido.");
    }

    // Validação
    const erros = [];
    if (!corpo.nome?.trim()) erros.push("nome é obrigatório.");
    if (!corpo.email?.trim()) erros.push("email é obrigatório.");
    if (corpo.email && !corpo.email.includes("@")) erros.push("email inválido.");
    if (usuarios.some(u => u.email === corpo.email)) erros.push("email já cadastrado.");

    if (erros.length > 0) {
      return enviarJSON(res, 422, { erro: "Dados inválidos.", detalhes: erros });
    }

    const novoUsuario = {
      id: proximoId++,
      nome: corpo.nome.trim(),
      email: corpo.email.trim().toLowerCase(),
      criadoEm: new Date().toISOString(),
    };

    usuarios.push(novoUsuario);

    res.setHeader("Location", `/usuarios/${novoUsuario.id}`);
    return enviarJSON(res, 201, novoUsuario);
  }

  // PUT /usuarios/:id
  if (pathname.match(/^\/usuarios\/\d+$/) && metodo === "PUT") {
    const id = extrairId(pathname);
    const indice = usuarios.findIndex(u => u.id === id);

    if (indice === -1) return enviarErro(res, 404, `Usuário ${id} não encontrado.`);

    let corpo;
    try {
      corpo = JSON.parse(await lerCorpo(req));
    } catch {
      return enviarErro(res, 400, "JSON inválido.");
    }

    const erros = [];
    if (corpo.nome !== undefined && !corpo.nome.trim()) erros.push("nome não pode ser vazio.");
    if (corpo.email !== undefined && !corpo.email.includes("@")) erros.push("email inválido.");
    if (corpo.email && usuarios.some(u => u.email === corpo.email && u.id !== id)) {
      erros.push("email já cadastrado por outro usuário.");
    }

    if (erros.length > 0) {
      return enviarJSON(res, 422, { erro: "Dados inválidos.", detalhes: erros });
    }

    usuarios[indice] = {
      ...usuarios[indice],
      ...(corpo.nome && { nome: corpo.nome.trim() }),
      ...(corpo.email && { email: corpo.email.trim().toLowerCase() }),
      atualizadoEm: new Date().toISOString(),
    };

    return enviarJSON(res, 200, usuarios[indice]);
  }

  // DELETE /usuarios/:id
  if (pathname.match(/^\/usuarios\/\d+$/) && metodo === "DELETE") {
    const id = extrairId(pathname);
    const indice = usuarios.findIndex(u => u.id === id);

    if (indice === -1) return enviarErro(res, 404, `Usuário ${id} não encontrado.`);

    const removido = usuarios.splice(indice, 1)[0];
    return enviarJSON(res, 200, {
      mensagem: `Usuário "${removido.nome}" removido com sucesso.`,
    });
  }

  // Rota não encontrada
  enviarErro(res, 404, `Rota ${metodo} ${pathname} não encontrada.`);
}

// ── Servidor ─────────────────────────────────────
const servidor = http.createServer(async (req, res) => {
  try {
    await roteador(req, res);
  } catch (erro) {
    console.error("Erro interno:", erro);
    enviarErro(res, 500, "Erro interno do servidor.");
  }
});

servidor.listen(3000, () => {
  console.log("🚀 API rodando em http://localhost:3000");
  console.log("📋 Rotas disponíveis:");
  console.log("   GET  /usuarios");
  console.log("   GET  /usuarios/:id");
  console.log("   POST /usuarios");
  console.log("   PUT  /usuarios/:id");
  console.log("   DELETE /usuarios/:id");
});

Testando a API

# Instale o curl ou use o httpie para testar

# GET todos
curl http://localhost:3000/usuarios

# GET com busca
curl "http://localhost:3000/usuarios?busca=ana"

# GET por ID
curl http://localhost:3000/usuarios/1

# POST — criar
curl -X POST http://localhost:3000/usuarios \
  -H "Content-Type: application/json" \
  -d '{"nome": "Beatriz", "email": "bia@email.com"}'

# PUT — atualizar
curl -X PUT http://localhost:3000/usuarios/1 \
  -H "Content-Type: application/json" \
  -d '{"nome": "Ana Paula Souza"}'

# DELETE
curl -X DELETE http://localhost:3000/usuarios/2

Ou crie um arquivo testar.js:

// testar.js
const BASE = "http://localhost:3000";

async function req(metodo, url, corpo = null) {
  const opcoes = {
    method: metodo,
    headers: { "Content-Type": "application/json" },
  };
  if (corpo) opcoes.body = JSON.stringify(corpo);

  const res = await fetch(`${BASE}${url}`, opcoes);
  const dados = await res.json();
  console.log(`\n${metodo} ${url} → ${res.status}`);
  console.log(JSON.stringify(dados, null, 2));
  return dados;
}

async function main() {
  await req("GET", "/");
  await req("GET", "/usuarios");
  await req("POST", "/usuarios", { nome: "Beatriz", email: "bia@email.com" });
  await req("GET", "/usuarios/3");
  await req("PUT", "/usuarios/1", { nome: "Ana Paula Souza" });
  await req("DELETE", "/usuarios/2");
  await req("GET", "/usuarios");
  await req("GET", "/usuarios/99"); // teste de 404
}

main().catch(console.error);
node testar.js

Servindo arquivos estáticos

const http = require("http");
const fs = require("fs/promises");
const path = require("path");

const TIPOS_MIME = {
  ".html": "text/html; charset=utf-8",
  ".css":  "text/css",
  ".js":   "application/javascript",
  ".json": "application/json",
  ".png":  "image/png",
  ".jpg":  "image/jpeg",
  ".svg":  "image/svg+xml",
  ".ico":  "image/x-icon",
  ".txt":  "text/plain",
};

const PASTA_PUBLICA = path.join(__dirname, "public");

async function servirArquivo(res, caminhoRelativo) {
  const caminho = path.join(PASTA_PUBLICA, caminhoRelativo);

  // Segurança — evita path traversal (../../etc/passwd)
  if (!caminho.startsWith(PASTA_PUBLICA)) {
    res.writeHead(403);
    return res.end("Acesso negado.");
  }

  try {
    const conteudo = await fs.readFile(caminho);
    const ext = path.extname(caminho);
    const tipo = TIPOS_MIME[ext] || "application/octet-stream";

    res.writeHead(200, { "Content-Type": tipo });
    res.end(conteudo);
  } catch (erro) {
    if (erro.code === "ENOENT") {
      res.writeHead(404);
      res.end("Arquivo não encontrado.");
    } else {
      res.writeHead(500);
      res.end("Erro ao ler arquivo.");
    }
  }
}

const servidor = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  let pathname = url.pathname;

  // Redireciona / para index.html
  if (pathname === "/") pathname = "/index.html";

  await servirArquivo(res, pathname);
});

servidor.listen(3000, () => console.log("http://localhost:3000"));

Eventos do servidor

const servidor = http.createServer(handler);

// Quando uma conexão TCP é estabelecida
servidor.on("connection", (socket) => {
  console.log(`Nova conexão de ${socket.remoteAddress}`);
});

// Quando ocorre um erro no servidor
servidor.on("error", (erro) => {
  if (erro.code === "EADDRINUSE") {
    console.error(`❌ Porta 3000 já está em uso.`);
    process.exit(1);
  }
  console.error("Erro no servidor:", erro);
});

// Quando o servidor fecha
servidor.on("close", () => {
  console.log("Servidor encerrado.");
});

servidor.listen(3000);

// Encerramento gracioso — termina requisições em andamento
process.on("SIGTERM", () => {
  console.log("Encerrando servidor...");
  servidor.close(() => {
    console.log("Servidor encerrado com sucesso.");
    process.exit(0);
  });
});

As limitações do http puro — por que existe o Express

Depois de tudo que construímos, ficam claras as limitações do módulo http puro:

Problema                          Com http puro
──────────────────────────────────────────────────────
Roteamento                        if/else manual e verboso
Parâmetros de URL (/users/:id)   Regex manual
Middleware (logging, auth, cors)  Encadeamento manual
Body parsing (JSON, form)         Stream manual
Upload de arquivos                Muito complexo
Sessões e cookies                 Manual
Compressão (gzip)                 Manual
HTTPS                             Configuração trabalhosa

O Express resolve a maior parte desses problemas com APIs limpas e elegantes, e os que sobram têm middleware pronto — compression para gzip, cookie-parser e express-session para sessão. O HTTPS é a exceção: ele continua sendo o mesmo trabalho de sempre, feito com o módulo https ou, o mais comum em produção, delegado ao proxy reverso. E agora que você entende o que há por baixo, vai usar o Express com muito mais consciência.

Tarefa para você

Estenda a mini API de usuários adicionando:

// 1. Persistência em arquivo JSON
//    - Ao iniciar, carrega usuarios.json se existir
//    - Após cada POST, PUT, DELETE salva no arquivo
//    - Use fs/promises para leitura e escrita

// 2. Paginação no GET /usuarios
//    GET /usuarios?pagina=1&por_pagina=10
//    Resposta deve incluir:
//    { dados, total, pagina, por_pagina, total_paginas }

// 3. Rota GET /usuarios/:id/posts
//    Busca posts do usuário na JSONPlaceholder:
//    https://jsonplaceholder.typicode.com/posts?userId={id}

// 4. Middleware de logging
//    Crie uma função que envolve o roteador e registra:
//    [timestamp] METHOD /path → STATUS (Xms)

// 5. Tratamento de Content-Type
//    Rejeite requisições POST/PUT que não enviem
//    Content-Type: application/json com erro 415
Ver solução — as cinco extensões da API, sem sair do módulo http
// ---- servidor.js
const http = require("node:http");
const fs = require("fs/promises");
const path = require("node:path");

const ARQUIVO = path.join(__dirname, "usuarios.json");
const PORTA = Number(process.env.PORT ?? 3000);

let usuarios = [];
let proximoId = 1;

// ---------------------------------------------------------------
// 1 — persistência em arquivo
// ---------------------------------------------------------------
async function carregar() {
  try {
    const conteudo = await fs.readFile(ARQUIVO, "utf-8");
    usuarios = JSON.parse(conteudo);
    proximoId = usuarios.reduce((maior, u) => Math.max(maior, u.id), 0) + 1;
  } catch (erro) {
    // ENOENT na primeira execução é esperado: o arquivo ainda não
    // existe. Qualquer outro erro (JSON corrompido, permissão) precisa
    // aparecer, senão o servidor sobe fingindo que o banco está vazio
    // e o próximo salvamento apaga tudo.
    if (erro.code !== "ENOENT") throw erro;
    usuarios = [];
  }
}

// Salvamento serializado: duas requisições simultâneas escrevendo no
// mesmo arquivo produzem JSON truncado. A fila garante uma de cada vez.
let escrevendo = Promise.resolve();

function salvar() {
  escrevendo = escrevendo.then(async () => {
    const temporario = `${ARQUIVO}.tmp`;
    // Escreve no temporário e RENOMEIA: rename é atômico no mesmo
    // sistema de arquivos. Se o processo morrer no meio, o arquivo
    // bom continua intacto em vez de virar metade de um JSON.
    await fs.writeFile(temporario, JSON.stringify(usuarios, null, 2), "utf-8");
    await fs.rename(temporario, ARQUIVO);
  });

  return escrevendo;
}

// ---------------------------------------------------------------
// Utilidades de resposta
// ---------------------------------------------------------------
function responder(res, status, dados) {
  const corpo = dados === null ? "" : JSON.stringify(dados);

  res.writeHead(status, {
    "Content-Type": "application/json; charset=utf-8",
    "Content-Length": Buffer.byteLength(corpo), // bytes, não caracteres
  });

  res.end(corpo);
}

function lerCorpo(req) {
  return new Promise((resolver, rejeitar) => {
    const partes = [];
    let tamanho = 0;

    req.on("data", (parte) => {
      tamanho += parte.length;

      // Sem teto, um POST de 2 GB derruba o processo por falta de
      // memória. É a defesa mais barata que existe.
      if (tamanho > 1_000_000) {
        rejeitar(Object.assign(new Error("Corpo grande demais"), { status: 413 }));
        req.destroy();
        return;
      }

      partes.push(parte);
    });

    req.on("error", rejeitar);

    req.on("end", () => {
      const texto = Buffer.concat(partes).toString("utf-8");
      if (texto === "") return resolver(null);

      try {
        resolver(JSON.parse(texto));
      } catch {
        rejeitar(Object.assign(new Error("JSON inválido"), { status: 400 }));
      }
    });
  });
}

// ---------------------------------------------------------------
// 5 — Content-Type obrigatório em POST e PUT
// ---------------------------------------------------------------
function exigirJSON(req) {
  if (!["POST", "PUT", "PATCH"].includes(req.method)) return;

  const tipo = req.headers["content-type"] ?? "";

  // startsWith e não ===: o header legítimo pode vir como
  // "application/json; charset=utf-8".
  if (!tipo.toLowerCase().startsWith("application/json")) {
    throw Object.assign(
      new Error("Use Content-Type: application/json"),
      { status: 415 }
    );
  }
}

// ---------------------------------------------------------------
// 2 — paginação
// ---------------------------------------------------------------
function paginar(lista, parametros) {
  const pagina = Math.max(1, Number(parametros.get("pagina") ?? 1) || 1);
  const porPagina = Math.min(
    100, // teto: sem ele, ?por_pagina=999999 vira um jeito fácil de derrubar a API
    Math.max(1, Number(parametros.get("por_pagina") ?? 10) || 10)
  );

  const inicio = (pagina - 1) * porPagina;

  return {
    dados: lista.slice(inicio, inicio + porPagina),
    total: lista.length,
    pagina,
    por_pagina: porPagina,
    total_paginas: Math.ceil(lista.length / porPagina) || 1,
  };
}

// ---------------------------------------------------------------
// 3 — posts do usuário, vindos de fora
// ---------------------------------------------------------------
async function buscarPosts(id) {
  // Timeout explícito: sem ele, uma API externa lenta segura a
  // requisição do seu usuário até o navegador desistir.
  const controlador = new AbortController();
  const limite = setTimeout(() => controlador.abort(), 5000);

  try {
    const resposta = await fetch(
      `https://jsonplaceholder.typicode.com/posts?userId=${id}`,
      { signal: controlador.signal }
    );

    if (!resposta.ok) {
      throw Object.assign(
        new Error(`API externa respondeu ${resposta.status}`),
        { status: 502 } // 502, não 500: o defeito é de terceiro
      );
    }

    return resposta.json();
  } catch (erro) {
    if (erro.name === "AbortError") {
      throw Object.assign(new Error("API externa demorou demais"), { status: 504 });
    }
    throw erro;
  } finally {
    clearTimeout(limite);
  }
}

// ---------------------------------------------------------------
// 4 — middleware de log, envolvendo o roteador
// ---------------------------------------------------------------
function comLog(roteador) {
  return async (req, res) => {
    const inicio = process.hrtime.bigint();

    // O log só pode sair quando a resposta terminar — antes disso o
    // status ainda não existe.
    res.on("finish", () => {
      const ms = Number(process.hrtime.bigint() - inicio) / 1e6;
      const carimbo = new Date().toISOString();
      console.log(
        `[${carimbo}] ${req.method} ${req.url} → ${res.statusCode} (${ms.toFixed(1)}ms)`
      );
    });

    return roteador(req, res);
  };
}

// ---------------------------------------------------------------
// O roteador
// ---------------------------------------------------------------
async function rotear(req, res) {
  const url = new URL(req.url, `http://${req.headers.host}`);
  const partes = url.pathname.split("/").filter(Boolean);

  exigirJSON(req);

  // GET /usuarios
  if (req.method === "GET" && partes.length === 1 && partes[0] === "usuarios") {
    return responder(res, 200, paginar(usuarios, url.searchParams));
  }

  // GET /usuarios/:id/posts
  if (req.method === "GET" && partes.length === 3 && partes[2] === "posts") {
    const id = Number(partes[1]);
    const usuario = usuarios.find((u) => u.id === id);

    if (!usuario) throw Object.assign(new Error("Usuário não encontrado"), { status: 404 });

    return responder(res, 200, { usuario: usuario.nome, posts: await buscarPosts(id) });
  }

  // GET /usuarios/:id
  if (req.method === "GET" && partes.length === 2 && partes[0] === "usuarios") {
    const usuario = usuarios.find((u) => u.id === Number(partes[1]));

    if (!usuario) throw Object.assign(new Error("Usuário não encontrado"), { status: 404 });

    return responder(res, 200, usuario);
  }

  // POST /usuarios
  if (req.method === "POST" && partes.length === 1 && partes[0] === "usuarios") {
    const corpo = await lerCorpo(req);

    if (!corpo?.nome || !corpo?.email) {
      throw Object.assign(new Error("nome e email são obrigatórios"), { status: 422 });
    }

    const novo = { id: proximoId++, nome: corpo.nome, email: corpo.email };
    usuarios.push(novo);
    await salvar();

    res.setHeader("Location", `/usuarios/${novo.id}`); // 201 pede o Location
    return responder(res, 201, novo);
  }

  // PUT /usuarios/:id
  if (req.method === "PUT" && partes.length === 2) {
    const indice = usuarios.findIndex((u) => u.id === Number(partes[1]));

    if (indice === -1) throw Object.assign(new Error("Usuário não encontrado"), { status: 404 });

    const corpo = await lerCorpo(req);
    usuarios[indice] = { ...usuarios[indice], ...corpo, id: usuarios[indice].id };
    await salvar();

    return responder(res, 200, usuarios[indice]);
  }

  // DELETE /usuarios/:id
  if (req.method === "DELETE" && partes.length === 2) {
    const indice = usuarios.findIndex((u) => u.id === Number(partes[1]));

    if (indice === -1) throw Object.assign(new Error("Usuário não encontrado"), { status: 404 });

    usuarios.splice(indice, 1);
    await salvar();

    return responder(res, 204, null); // 204 não tem corpo
  }

  throw Object.assign(new Error("Rota não encontrada"), { status: 404 });
}

const servidor = http.createServer(
  comLog(async (req, res) => {
    try {
      await rotear(req, res);
    } catch (erro) {
      const status = erro.status ?? 500;

      // Erro 500 é bug: registra o stack no servidor e mostra ao
      // cliente uma mensagem genérica. Vazar stack trace numa API
      // pública entrega estrutura de diretórios e versões.
      if (status === 500) console.error(erro);

      responder(res, status, {
        erro: status === 500 ? "Erro interno" : erro.message,
      });
    }
  })
);

carregar().then(() => {
  servidor.listen(PORTA, () => console.log(`http://localhost:${PORTA}`));
});

// Encerramento limpo: sem isto, Ctrl+C mata conexões no meio e pode
// deixar o usuarios.json.tmp para trás.
process.on("SIGINT", () => {
  console.log("\nencerrando...");
  servidor.close(() => process.exit(0));
});

Escrever direto no arquivo final é como se perde o banco inteiro: se o processo morrer no meio do writeFile, sobra metade de um JSON e o JSON.parse do próximo boot falha. Escreva num .tmp e use rename, que é atômico — e serialize as escritas, senão duas requisições simultâneas se atropelam.

Depois de escrever o roteamento no if, ler o corpo pedaço a pedaço e definir o Content-Type à mão, fica claro o que um framework faz — e, o que importa mais, o que ele continua não fazendo por você. O res.end() chamado duas vezes, o Content-Length medido em bytes e não em caracteres, o Host que vem do cliente e não do servidor: são detalhes do protocolo, e eles seguem valendo com ou sem Express no caminho.

Fontes e Referências

Exercícios

Exercício 1

Um return foi esquecido. O que o cliente recebe ao pedir GET /usuarios/99, e o que aparece no terminal do servidor?

if (pathname.match(/^\/usuarios\/\d+$/) && metodo === "GET") {
  const usuario = usuarios.find(u => u.id === extrairId(pathname));

  if (!usuario) enviarErro(res, 404, "Usuário não encontrado.");
  return enviarJSON(res, 200, usuario);
}
Ver resposta

✓ Resposta: O cliente recebe o 404, corretamente — e o servidor derruba um erro no terminal: Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client. Sem o return, a execução não para no enviarErro: ela segue para o enviarJSON, que tenta chamar writeHead numa resposta já enviada. O primeiro end() vence e é o que o cliente vê; o segundo lança. Repare no encadeamento completo neste servidor: como o roteador está dentro de um try/catch que responde 500, o erro é capturado e o enviarErro(res, 500, ...) do catch tenta responder de novo — e falha pelo mesmo motivo, agora sem ninguém para capturar. É por isso que o artigo escreve return enviarJSON(...) em toda rota: o return não serve para devolver valor nenhum, serve para encerrar o roteador. Duas defesas valem a pena: checar res.headersSent antes de responder dentro de um manipulador de erro, e desconfiar de qualquer if de rota sem return, que é onde esse defeito nasce.

Exercício 2

Esta é a proteção de 10 MB do artigo. Um cliente envia um corpo de 2 GB. O que acontece com a memória do processo?

req.on("data", (chunk) => {
  chunks.push(chunk);

  const totalBytes = chunks.reduce((acc, c) => acc + c.length, 0);
  if (totalBytes > 10 * 1024 * 1024) {
    reject(new Error("Corpo da requisição muito grande."));
  }
});
Ver resposta

✓ Resposta: A memória continua subindo até o processo morrer. O reject resolve a promise, não a conexão: o cliente segue enviando, o evento data segue disparando, e cada pedaço continua sendo empurrado no array. Rejeitar uma promise já rejeitada não faz nada, então nem erro aparece — a proteção informa que o limite foi ultrapassado e não impede coisa alguma. Faltam duas linhas: req.destroy(), que encerra a conexão e interrompe o fluxo na origem, e um return para não acumular o pedaço atual. Há um segundo defeito no mesmo trecho, de desempenho: o reduce percorre todos os pedaços a cada pedaço novo, o que faz o custo crescer com o quadrado do número de chunks — num corpo grande, o servidor passa mais tempo somando do que recebendo. O certo é um acumulador: tamanho += chunk.length. E vale conhecer a defesa que vem antes de todas: olhar o header Content-Length e recusar com 413 antes de ler o primeiro byte — embora ele não substitua a contagem, já que o cliente pode mentir ou usar Transfer-Encoding: chunked e não mandar header nenhum.

Exercício 3

O nome do usuário é José Café. O cliente recebe o JSON cortado no meio. Por quê?

const corpo = JSON.stringify({ id: 1, nome: "José Café" });

res.writeHead(200, {
  "Content-Type": "application/json; charset=utf-8",
  "Content-Length": corpo.length,
});

res.end(corpo);
Ver resposta

✓ Resposta: Porque Content-Length é medido em bytes e String.length conta unidades de código UTF-16. O é e o é de "José Café" ocupam dois bytes cada em UTF-8, mas contam como um caractere cada — a string tem 30 caracteres e 32 bytes. O servidor anuncia 30, o cliente lê exatamente 30 bytes e para, e o JSON chega sem os dois últimos, o que produz um erro de parse que aponta para o fim do documento e não diz nada sobre acento. O sintoma engana porque só aparece com caracteres não-ASCII: em desenvolvimento, com nomes como "Ana Silva", tudo funciona; em produção, com "José", "Conceição" e emoji, quebra. A medida correta é Buffer.byteLength(corpo), que conta os bytes da codificação de fato. Duas observações práticas: se você simplesmente não declarar o Content-Length, o Node usa Transfer-Encoding: chunked e acerta sozinho — declarar é otimização, não obrigação; e o mesmo cuidado vale para qualquer limite de tamanho, porque um campo de 500 caracteres pode passar de 1000 bytes.

Exercício 4

Esta é a proteção contra path traversal do artigo. Ela barra /../../etc/passwd. Existe alguma coisa que ela deixa passar?

const PASTA_PUBLICA = path.join(__dirname, "public");

const caminho = path.join(PASTA_PUBLICA, caminhoRelativo);

if (!caminho.startsWith(PASTA_PUBLICA)) {
  res.writeHead(403);
  return res.end("Acesso negado.");
}
Ver resposta

✓ Resposta: Deixa passar qualquer pasta irmã cujo nome comece com o mesmo prefixo. Se existir public-backup ao lado de public, o pedido /../public-backup/dump.sql resulta no caminho /app/public-backup/dump.sql, que começa com /app/public — e o startsWith aprova. O path.join faz a parte dele: ele normaliza e resolve os .., e é por isso que o ataque clássico do /etc/passwd é barrado. Quem falha é a comparação, que trata caminho como texto e ignora que o separador de diretório é o que define a fronteira. A correção é comparar com a barra incluída — caminho.startsWith(PASTA_PUBLICA + path.sep) — ou, melhor, usar path.relative(PASTA_PUBLICA, caminho) e rejeitar quando o resultado começa com .. ou é absoluto, que é a forma que funciona igual no Windows. E vale lembrar que nomes de arquivo chegam codificados: um %2e%2e%2f só vira ../ depois da decodificação, então a validação tem de acontecer sobre o valor já decodificado, nunca sobre a URL crua.

Exercício 5

Esta linha aparece em todos os exemplos do artigo. De onde vem req.headers.host, e o que um cliente mal-intencionado consegue fazer com ela?

const url = new URL(req.url, `http://${req.headers.host}`);

// e, mais adiante, num email de recuperação de senha:
const link = `${url.origin}/redefinir?token=${token}`;
Ver resposta

✓ Resposta: Vem do próprio clienteHost é um cabeçalho da requisição, digitado por quem faz a chamada, e nada impede curl -H "Host: servidor-do-atacante.com". Para extrair pathname e searchParams isso é inofensivo: a base existe só porque o new URL exige uma, e o caminho vem de req.url. O problema aparece no momento em que esse valor volta para fora, como no link do email: o servidor gera https://servidor-do-atacante.com/redefinir?token=... e envia ao usuário legítimo, que clica e entrega o token de redefinição de senha. O ataque tem nome, host header injection, e é uma via conhecida de sequestro de conta justamente porque o email parte do remetente verdadeiro. A defesa não é sanitizar o header: é não confiar nele para nada que saia do servidor — a URL pública fica numa variável de ambiente, como APP_URL. Para a leitura da requisição, o padrão seguro é uma base fixa e descartável, do tipo new URL(req.url, "http://interno"), deixando claro que aquele host não significa nada. E, quando o servidor está atrás de um proxy, lembre que X-Forwarded-Host e X-Forwarded-For têm exatamente o mesmo problema: só valem se o proxy os reescrever.

Comentários

Mais em Javascript

Temporizadores: setTimeout e setInterval
Temporizadores: setTimeout e setInterval

Um relógio que desconta um segundo a cada tique sempre atrasa: setInterval…

APIs públicas: exemplos práticos
APIs públicas: exemplos práticos

Seis APIs públicas, nenhuma exigindo cadastro ou chave: ViaCEP para endereço…

Projeto Final: Revisão Completa e Aplicação de Produção
Projeto Final: Revisão Completa e Aplicação de Produção

O fim da série reúne tudo numa aplicação de produção: React com rotas e…