Dia 6 — Rotas

Informatica · Conteudo · publicado em 30/09/2026
Aula 1

Ler a URL e separar os caminhos

Ler a URL e separar os caminhos

req.url chega como uma string só. O cliente manda

/alunos?turma=3&ordem=nome e o servidor é quem precisa tirar as duas partes:

o caminho diz qual rota é, a query string diz os parâmetros.

Separar isso com split('?') funciona até o dia em que um valor tem ? dentro

(?busca=a?b), e aí o resultado é silenciosamente errado. URL e

URLSearchParams existem para isso.

As duas APIs antigas ainda são o que a maior parte da documentação e do Stack

Overflow mostra. url.parse é o método do módulo node:url que devolve um

objeto com pathname e query, e querystring é o módulo que fazia a

segunda parte do trabalho — querystring.parse(u.query). As duas existem e

funcionam, mas cada uma erra por um motivo diferente, e URL erra por um só,

que é não existir.

const u = new URL(req.url, 'http://127.0.0.1:3000');
u.pathname;                    // '/alunos'
u.searchParams.get('turma');   // '3'

O segundo argumento é a base, e é o que o navegador usa para transformar

/alunos em endereço absoluto. No servidor, a base é o próprio endereço.

pathname e search

PropriedadeO que devolve
u.pathnamesó o caminho: /alunos/7
u.searcha query string inteira, com ?: ?turma=3
u.searchParamsos parâmetros já separados
u.searchParams.sizequantos parâmetros (chaves repetidas contam uma vez)

O searchParams é o que resolve parâmetro de URL: um nome, um valor, sempre

texto, e a decodificação já feita (%20 já chegou como espaço).

pathname é a base de todo roteamento: a aula 2 de hoje é o if que compara

pathname com os caminhos e escolhe a resposta. O caminho de raiz, /, é a

rota inicial de qualquer servidor — é ele que responde quando alguém digita o

endereço sem pedir nada, e quase sempre é a que devolve a lista de rotas ou um

200 confirmando que a API está de pé.

get devolve null, e Number(null) é 0

Esse é o defeito que só aparece com dado faltando, e é a pegadinha mais cara de

validação de query string.

u.searchParams.get('turma')   // '3'      — texto, sempre string
u.searchParams.get('inexistente')  // null  — não string vazia
Number(null)                  // 0        — não NaN
Number('')                    // 0        — idem
Number('abc')                 // NaN

A validação que só testa Number.isInteger(Number(valor)) deixa passar o

parâmetro ausente: Number(null) é 0, que é inteiro, e o pedido vai para o

banco filtrando turma = 0. O que funciona é testar a ausência antes de

converter:

const t = u.searchParams.get('turma');
if (t === null || !/^\d+$/.test(t)) return respostaDeErro(400);

get e getAll: parâmetro repetido

?tag=node&tag=mysql tem dois valores com a mesma chave. get('tag') devolve o

primeiro; getAll('tag') devolve os dois, em array. Escolher entre os dois é

decisão de API, e é por isso que a escolha precisa ser explícita no desenho da

rota.

URLSearchParams montando de volta

O mesmo objeto serve para construir query string: set, append, delete,

has, entries e toString(). O toString() escapa os valores sozinho: um espaço vira + (que o servidor

decodifica de volta para espaço) e um & dentro do valor vira %26 — que é

exatamente a pegadinha do ?busca=a?b que abriu esta aula. Não se escapa à mão.

Exemplo

'use strict';

// Exemplo da aula 1 do dia 6: ler a URL e separar caminho de query string.
//
// `req.url` nao vem separado. O cliente manda uma coisa so — `"/alunos?turma=3&ordem=nome"` —
// e e o servidor que precisa tirar as duas partes. `URL` e `URLSearchParams` fazem
// essa separacao sem(regex, e sem `split('?')` manual que quebra quando a query
// tem `?` dentro do valor.
//
// O que o exemplo prova, alem do parsing: `searchParams.get` devolve `null` para
// parametro ausente (e nao `undefined`, e nao string vazia), e `Number()` de um
// `null` vira `0` em vez de `NaN` — a pegadinha classica de validacao.

const http = require('node:http');

async function main() {
  const servidor = http.createServer((req, res) => {
    // O caminho cru, exatamente como o cliente mandou.
    res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end(req.url);
  });

  await new Promise((resolve) => servidor.listen(0, '127.0.0.1', resolve));
  const { port } = servidor.address();
  const base = `http://127.0.0.1:${port}`;

  try {
    console.log('servidor no ar em ' + base);
    console.log('(a porta muda a cada execucao: o `listen(0)` pediu uma livre)');

    // --- 1. `req.url` cru: uma string so ---
    const pedidos = [
      '/',
      '/alunos',
      '/alunos/7',
      '/alunos?turma=3&ordem=nome',
      '/alunos?turma=3&ordem=nome&limite=10&offset=20',
      '/alunos?turma=3&busca=ana%20maria&sinal=%26%3F',
      '/alunos?turma=',
      '/alunos?turma=abc',
      '/alunos?busca=a?b',
    ];

    console.log('');
    console.log('--- o que o req.url traz, caminho e query string misturados ---');
    for (const caminho of pedidos) {
      const r = await fetch(base + caminho);
      console.log('  ' + (caminho === '/' ? '/' : caminho).padEnd(46) + ' -> ' + await r.text());
    }

    // --- 2. `new URL`: separa o que e o que ---
    //
    // `new URL(requisicao, base)` exige a URL completa. O `base` e o que o
    // navegador usa para transformar `/alunos` em endereco absoluto — e aqui e
    // o endereco do proprio servidor.
    console.log('');
    console.log('--- new URL separa o caminho da query string ---');
    for (const caminho of pedidos) {
      const u = new URL(caminho, base);
      console.log('  ' + caminho.padEnd(46));
      console.log('      pathname  ' + u.pathname);
      console.log('      search    ' + u.search);
      console.log('      searchParams.size ' + u.searchParams.size +
        ' | params ' + JSON.stringify([...u.searchParams]));
    }

    // --- 3. `searchParams`: o parametro por nome ---
    const u = new URL('/alunos?turma=3&ordem=nome&limite=10&busca=ana%20maria', base);
    console.log('');
    console.log('--- searchParams ---');
    console.log("get('turma'):    ", u.searchParams.get('turma'), '  <- texto, sempre string');
    console.log("get('limite'):   ", u.searchParams.get('limite'));
    console.log("get('ordem'):    ", u.searchParams.get('ordem'));
    console.log("get('busca'):    ", JSON.stringify(u.searchParams.get('busca')), ' <- o %20 ja virou espaco');
    console.log("get('inexistente'):", u.searchParams.get('inexistente'), ' <- null, nao string vazia');
    console.log("has('turma'):     ", u.searchParams.has('turma'), "| has('inexistente'):", u.searchParams.has('inexistente'));

    // `get` devolve a PRIMEIRA ocorrencia; `getAll` devolve todas. E a diferenca
    // entre `?tag=a&tag=b` lido de duas formas.
    const repetido = new URL('/alunos?tag=node&tag=mysql&tag=http', base);
    console.log("get('tag'):     ", repetido.searchParams.get('tag'), ' <- so o primeiro');
    console.log("getAll('tag'):  ", JSON.stringify(repetido.searchParams.getAll('tag')));

    // --- 4. a pegadinha: `Number(null)` e `Number('')` ---
    //
    // Um parametro ausente devolve `null`, e `Number(null)` e `0` — nao `NaN`.
    // A validacao que so testa `NaN` deixa passar o 0 e devolve "turma 0" para
    // quem nao mandou nada. E o defeito que so aparece com dado faltando.
    console.log('');
    console.log('--- a pegadinha do parametro ausente ---');
    const semTurma = new URL('/alunos', base);
    const valor = semTurma.searchParams.get('turma');
    console.log("get('turma') sem mandar:      ", valor, '(tipo ' + typeof valor + ')');
    console.log('Number(valor):               ', Number(valor), '<-- 0, e nao NaN');
    console.log('Number(null):                ', Number(null));
    console.log("Number(''):                  ", Number(''), '<-- tambem 0');
    console.log("Number('abc'):               ", Number('abc'), '<-- este sim e NaN');
    console.log('');
    console.log('a validacao que SO testa se virou numero, e que por isso passa:');
    console.log('  Number.isInteger(Number(valor)) ->', Number.isInteger(Number(valor)),
      ' <-- true, e o pedido segue para o banco filtrando turma = 0');
    console.log('');
    console.log('a validacao que testa a AUSENCIA antes de converter:');
    console.log('  Number.isInteger(valor) ->', Number.isInteger(valor),
      ' <-- false, porque null nao e inteiro');
    console.log('  Number(null) === 0:', Number(null) === 0, '<-- a razao de a primeira passar');
    console.log('  em codigo: if (t === null || !/^[0-9]+$/.test(t)) -> 400');

    // --- 5. montar query string de volta ---
    const nova = new URLSearchParams();
    nova.set('turma', '3');
    nova.set('ordem', 'nome');
    nova.set('busca', 'ana maria');
    console.log('');
    console.log('--- URLSearchParams montando de volta ---');
    console.log('nova.toString():', nova.toString());
    // `+` NAO e o mesmo que `%20` para o `decodeURIComponent`, que nao sabe
    // desse atalho. Quem sabe e o proprio `URLSearchParams`, e e ele que o
    // servidor usa para ler: o valor volta igual.
    const relido = new URLSearchParams(nova.toString());
    console.log('o espaco saiu como "+"; relendo com URLSearchParams:',
      JSON.stringify(relido.get('busca')));
    // E o `&` dentro do valor, que e o caso perigoso: sem escapar ele viraria
    // um parametro novo e o servidor leria `busca=ana` + ` maria` sem valor.
    const perigoso = new URLSearchParams();
    perigoso.set('busca', 'ana & maria');
    console.log('com & dentro do valor:', perigoso.toString());
    console.log('  o servidor le de volta:', JSON.stringify(new URLSearchParams(perigoso).get('busca')));
    console.log('tem get/set/delete/has/entries, e e o mesmo objeto dos dois lados');
  } finally {
    await new Promise((resolve) => servidor.close(resolve));
    console.log('');
    console.log('servidor encerrado com close().');
  }
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

servidor no ar em http://127.0.0.1:44921
(a porta muda a cada execucao: o `listen(0)` pediu uma livre)

--- o que o req.url traz, caminho e query string misturados ---
  /                                              -> /
  /alunos                                        -> /alunos
  /alunos/7                                      -> /alunos/7
  /alunos?turma=3&ordem=nome                     -> /alunos?turma=3&ordem=nome
  /alunos?turma=3&ordem=nome&limite=10&offset=20 -> /alunos?turma=3&ordem=nome&limite=10&offset=20
  /alunos?turma=3&busca=ana%20maria&sinal=%26%3F -> /alunos?turma=3&busca=ana%20maria&sinal=%26%3F
  /alunos?turma=                                 -> /alunos?turma=
  /alunos?turma=abc                              -> /alunos?turma=abc
  /alunos?busca=a?b                              -> /alunos?busca=a?b

--- new URL separa o caminho da query string ---
  /                                             
      pathname  /
      search    
      searchParams.size 0 | params []
  /alunos                                       
      pathname  /alunos
      search    
      searchParams.size 0 | params []
  /alunos/7                                     
      pathname  /alunos/7
      search    
      searchParams.size 0 | params []
  /alunos?turma=3&ordem=nome                    
      pathname  /alunos
      search    ?turma=3&ordem=nome
      searchParams.size 2 | params [["turma","3"],["ordem","nome"]]
  /alunos?turma=3&ordem=nome&limite=10&offset=20
      pathname  /alunos
      search    ?turma=3&ordem=nome&limite=10&offset=20
      searchParams.size 4 | params [["turma","3"],["ordem","nome"],["limite","10"],["offset","20"]]
  /alunos?turma=3&busca=ana%20maria&sinal=%26%3F
      pathname  /alunos
      search    ?turma=3&busca=ana%20maria&sinal=%26%3F
      searchParams.size 3 | params [["turma","3"],["busca","ana maria"],["sinal","&?"]]
  /alunos?turma=                                
      pathname  /alunos
      search    ?turma=
      searchParams.size 1 | params [["turma",""]]
  /alunos?turma=abc                             
      pathname  /alunos
      search    ?turma=abc
      searchParams.size 1 | params [["turma","abc"]]
  /alunos?busca=a?b                             
      pathname  /alunos
      search    ?busca=a?b
      searchParams.size 1 | params [["busca","a?b"]]

--- searchParams ---
get('turma'):     3   <- texto, sempre string
get('limite'):    10
get('ordem'):     nome
get('busca'):     "ana maria"  <- o %20 ja virou espaco
get('inexistente'): null  <- null, nao string vazia
has('turma'):      true | has('inexistente'): false
get('tag'):      node  <- so o primeiro
getAll('tag'):   ["node","mysql","http"]

--- a pegadinha do parametro ausente ---
get('turma') sem mandar:       null (tipo object)
Number(valor):                0 <-- 0, e nao NaN
Number(null):                 0
Number(''):                   0 <-- tambem 0
Number('abc'):                NaN <-- este sim e NaN

a validacao que SO testa se virou numero, e que por isso passa:
  Number.isInteger(Number(valor)) -> true  <-- true, e o pedido segue para o banco filtrando turma = 0

a validacao que testa a AUSENCIA antes de converter:
  Number.isInteger(valor) -> false  <-- false, porque null nao e inteiro
  Number(null) === 0: true <-- a razao de a primeira passar
  em codigo: if (t === null || !/^[0-9]+$/.test(t)) -> 400

--- URLSearchParams montando de volta ---
nova.toString(): turma=3&ordem=nome&busca=ana+maria
o espaco saiu como "+"; relendo com URLSearchParams: "ana maria"
com & dentro do valor: busca=ana+%26+maria
  o servidor le de volta: "ana & maria"
tem get/set/delete/has/entries, e e o mesmo objeto dos dois lados

servidor encerrado com close().
Aula 2

if/else de rotas e resposta JSON

if/else de rotas e resposta JSON

Um servidor que responde 200 para qualquer caminho não serve para nada. O

roteamento é o que transforma o req em uma resposta útil: o caminho decide

qual função roda, e o método decide se ela aceita aquele pedido.

A ordem: caminho primeiro, metodo depois

A ordem das checagens é o que importa, e o inverso é o defeito clássico. O

if que compara o pathname é o if de rota, e ele vem antes de qualquer

comparação de método.

const rota = rotas[caminho];
if (!rota) return responder(res, 404, { erro: 'rota nao encontrada' });
if (!rota[metodo]) return responder(res, 405, { erro: 'metodo nao permitido' });
return rota[metodo](corpo);

Testar o método antes do caminho junta /alunos com GET e /professores com

POST no mesmo if (req.method === 'POST'), e o segundo pedido recebe a

resposta da rota errada. Caminho primeiro, método depois, sempre.

SituaçãoStatus
o caminho não existe404, com o caminho no corpo
o caminho existe, o método não405, com Allow: GET, POST
o caminho e o método existem, mas o dado está errado400
deu certo e criou recurso201

O 405 é o único status do grupo que tem cabeçalho próprio: o Allow diz quais

métodos aquela rota aceita, e é ele que faz o curl sugerir o próximo comando.

O 404 é o status 404 no servidor — o caminho não está na tabela, e a resposta

precisa dizer qual caminho chegou, senão quem está testando não sabe o que

digitou de errado.

O outro lado do par é o método POST, que é o que cria: o mesmo caminho /alunos

responde GET para a lista e POST para a criação, e são duas funções

diferentes na mesma entrada da tabela de rotas.

res.json não existe no módulo nativo

res.json é do Express, não do node:http. Nativo, a resposta JSON são três

linhas:

const texto = JSON.stringify(corpo);
res.writeHead(status, {
  'Content-Type': 'application/json; charset=utf-8',
  'Content-Length': Buffer.byteLength(texto),
});
res.end(texto);

O Content-Type é o que faz o cliente entender que aquilo é JSON. Sem ele, o

fetch precisa de .text() e o navegador baixa o arquivo em vez de mostrar.

O Content-Length em Buffer.byteLength conta bytes, não caracteres — texto

com acento ocupa mais de um byte, e é por isso que texto.length dá número

errado.

O par Content-Type e Content-Length é o cabeçalho de resposta que toda API

devolve, e ele vai no writeHead porque precisa sair na mesma hora em que o

status sai: cabeçalho escrito depois do corpo já chegou ao cliente não corrige

nada.

writeHead e statusCode dizem o mesmo

res.writeHead(404, { ... });      // caminho curto
res.statusCode = 404;             // mesma resposta
res.setHeader('Content-Type', 'application/json');
res.end(texto);

O writeHead é o caminho curto. O statusCode separado só é útil quando o

status é decidido longe do ponto onde a resposta é escrita — que é o caso de

try/catch e do finally do servidor.

Uma função por rota que devolve { status, corpo }

O padrão que o material usa daqui em diante: cada rota devolve status e corpo,

e quem escreve na resposta é o roteador. Isso dá dois benefícios reais — a mesma

rota pode ser testada sem subir servidor, e a lista de rotas vira uma lista de

funções que dá para imprimir e conferir.

O par caminho + método é o endpoint: GET /alunos/7 é um endpoint, e o mesmo

caminho com DELETE é outro. É o termo que documentação, teste e cliente usam

para nomear a mesma coisa.

Com uma tabela de rotas não existe switch de rota: o switch (caminho) com

case para cada caminho funciona, mas cresce de um case por endpoint e

ignora o método. A tabela resolve os dois de uma vez, porque a chave é o

caminho e o valor é o objeto de métodos.

const resultado = await rotas['/alunos'].POST({ nm_aluno: 'Diego' });
// { status: 201, corpo: { id: 4, nm_aluno: 'Diego' } }

Ler o corpo da requisição — o on('data'), o JSON.parse e o corpo vazio — é

a aula 1 do dia 7. Enquanto isso, req.corpo é null, e por isso um POST que

manda nm_aluno ainda volta 400: a validação está certa, o dado é que não

chegou.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 6: o roteador, com `if` e resposta JSON.
//
// Um servidor que responde 200 para tudo e um servidor que nao serve para nada.
// O roteamento e o que transforma o `req` em uma resposta util: o caminho
// decide qual funcao roda, o metodo decide se ela aceita aquele metodo, e o
// `JSON.stringify` entrega o resultado no formato que o cliente espera.
//
// A ordem das checagens e o que importa: caminho primeiro, metodo depois. E o
// inverso (testar o metodo antes do caminho) e o defeito classico: `/alunos` com
// GET e `/professores` com POST caem no mesmo `if (req.method === 'POST')` e o
// segundo devolve a resposta da rota errada.

const http = require('node:http');

async function main() {
  // Os dados ficam aqui em memoria porque a aula e sobre ROTA, e o banco entra
  // no dia 12. Uma tabela com duas linhas e o suficiente para a resposta JSON
  // mostrar a forma.
  const alunos = [
    { id: 1, nm_aluno: 'Ana', turma: 3 },
    { id: 2, nm_aluno: 'Bruno', turma: 3 },
    { id: 3, nm_aluno: 'Carla', turma: 4 },
  ];

  // Uma funcao por rota. Devolve { status, corpo } e NAO escreve na resposta:
  // quem escreve e o roteador, e e assim que o mesmo objeto serve para o
  // `res.json` do Express de um dia qualquer.
  const rotas = {
    '/alunos': {
      GET: () => ({ status: 200, corpo: { total: alunos.length, dados: alunos } }),
      // `async` porque toda rota do material e assincrona: quando ela tocar o
      // banco (dia 12) a consulta entra aqui e devolve promessa. O roteador
      // espera, e por isso a resposta sai na mesma ordem de todo mundo.
      POST: async (corpo) => {
        if (!corpo || !corpo.nm_aluno) {
          return { status: 400, corpo: { erro: 'nm_aluno e obrigatorio' } };
        }
        return { status: 201, corpo: { id: alunos.length + 1, nm_aluno: corpo.nm_aluno } };
      },
    },
    '/saude': {
      GET: () => ({ status: 200, corpo: { ok: true } }),
    },
  };

  // 404 e 405 ficam no roteador, nao nas rotas: sao as duas respostas que o
  // servidor deve dar quando NAO achou quem atender.
  const roteador = async (req, res) => {
    const caminho = new URL(req.url, 'http://127.0.0.1').pathname;
    const metodo = req.method;
    const rota = rotas[caminho];

    // --- caminho primeiro ---
    if (!rota) {
      return responder(res, 404, {
        erro: 'rota nao encontrada',
        caminho,
        metodo,
      });
    }

    // --- metodo depois ---
    if (!rota[metodo]) {
      res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8', Allow: Object.keys(rota).join(', ') });
      return res.end(JSON.stringify({ erro: 'metodo nao permitido', permitido: Object.keys(rota) }));
    }

    // A rota e assincrona e devolve promessa: o roteador espera e so entao
    // escreve na resposta. Sem o `await`, o `res.end` sairia antes da consulta
    // ao banco e o cliente leria uma resposta vazia.
    const resultado = await rota[metodo](await corpoDoPedido(req));
    responder(res, resultado.status, resultado.corpo);
  };

  // O corpo do POST chega como texto e precisa virar objeto: e a aula 1 do dia 7
  // que faz isso direito. Aqui o roteador so precisa passar o que tem, e a rota
  // decide se usa.
  function corpoDoPedido(req) {
    return Promise.resolve(req.corpo || null);
  }

  // `res.json` nao existe no modulo nativo: e `writeHead` + `JSON.stringify` +
  // `end`, e o `Content-Type` que faz o cliente entender que aquilo e JSON. Sem
  // o cabecalho, o `fetch` precisa de `.text()` e o navegador baixa o arquivo.
  function responder(res, status, corpo) {
    const texto = JSON.stringify(corpo);
    res.writeHead(status, {
      'Content-Type': 'application/json; charset=utf-8',
      'Content-Length': Buffer.byteLength(texto),
    });
    res.end(texto);
  }

  const servidor = http.createServer(roteador);
  await new Promise((resolve) => servidor.listen(0, '127.0.0.1', resolve));
  const { port } = servidor.address();
  const base = `http://127.0.0.1:${port}`;

  try {
    console.log('servidor no ar em ' + base);
    console.log('(a porta muda a cada execucao: o `listen(0)` pediu uma livre)');
    console.log('rotas declaradas:', Object.keys(rotas).join(', '));

    // --- 1. rota que existe ---
    console.log('');
    console.log('--- GET /alunos (200) ---');
    let r = await fetch(base + '/alunos');
    console.log('status:', r.status, '| content-type:', r.headers.get('content-type'));
    console.log('corpo:', await r.text());

    // --- 2. caminho que nao existe: 404 ---
    console.log('');
    console.log('--- GET /nao-existe (404) ---');
    r = await fetch(base + '/nao-existe');
    console.log('status:', r.status);
    console.log('corpo:', await r.text());

    // --- 3. caminho que existe, metodo que nao: 405 ---
    //
    // Este e o caso que o roteador de "caminho primeiro, metodo depois" trata
    // certo. `DELETE /alunos` existe como caminho e nao como metodo, e a
    // resposta tem de ser 405 com o header `Allow` — nao 404, porque o caminho
    // existe; e nao 200, porque o metodo nao e aceito.
    console.log('');
    console.log('--- DELETE /alunos (405, o caminho existe) ---');
    r = await fetch(base + '/alunos', { method: 'DELETE' });
    console.log('status:', r.status, '| header Allow:', r.headers.get('allow'));
    console.log('corpo:', await r.text());

    // --- 4. POST sem o campo obrigatorio: 400 ---
    console.log('');
    console.log('--- POST /alunos sem nm_aluno (400) ---');
    // Este POST manda corpo, mas o roteador ainda NAO le o corpo: a aula 1 do
    // dia 7 monta o `on('data')`. Ate la, `corpoDoPedido` devolve `null` e a
    // validacao de `nm_aluno` e o que se ve acontecer.
    r = await fetch(base + '/alunos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ turma: 3 }),
    });
    console.log('status:', r.status);
    console.log('corpo:', await r.text());

    // --- 5. a MESMA rota, chamada direto, agora com o corpo ---
    //
    // Os dois POST acima mandaram `nm_aluno` diferente e os dois voltaram 400,
    // porque o roteador ainda NAO le o corpo da requisicao — o `req.corpo` e
    // `null` ate a aula 1 do dia 7 montar o `on('data')`.
    //
    // A rota em si nao tem esse defeito: ela funciona, e e o que a chamada
    // direta mostra. E e o motivo do padrao "rota devolve { status, corpo }": a
    // rota pode ser testada sem subir servidor nenhum, e sem `fetch`.
    console.log('');
    console.log('--- a rota POST chamada direto, com o corpo na mao ---');
    const comNome = await rotas['/alunos'].POST({ nm_aluno: 'Diego', turma: 3 });
    console.log('status:', comNome.status, '(201 = criou; 200 seria "deu certo, sem criar nada")');
    console.log('corpo:', JSON.stringify(comNome.corpo));
    const semNome = await rotas['/alunos'].POST({ turma: 3 });
    console.log('sem nm_aluno -> status:', semNome.status, '| corpo:', JSON.stringify(semNome.corpo));
    console.log('');
    console.log('e o `Allow` da rota 405 vem da mesma fonte:', Object.keys(rotas['/alunos']).join(', '));

    // --- 6. `res.statusCode` e `writeHead` dizem o mesmo ---
    console.log('');
    console.log('--- os dois jeitos de mandar o status ---');
    console.log('res.writeHead(404, { ... }); res.end(corpo)');
    console.log('res.statusCode = 404; res.setHeader(...); res.end(corpo)');
    console.log('os dois produzem a mesma resposta; o writeHead e o caminho curto.');

    // --- 7. o header `Allow` e a resposta bemEducada do 405 ---
    console.log('');
    console.log('--- 405 e o unico status que tem header proprio ---');
    console.log('ele diz QUAIS metodos a rota aceita, e e o que o `Allow` acima transportou');
  } finally {
    await new Promise((resolve) => servidor.close(resolve));
    console.log('');
    console.log('servidor encerrado com close().');
  }
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

servidor no ar em http://127.0.0.1:40401
(a porta muda a cada execucao: o `listen(0)` pediu uma livre)
rotas declaradas: /alunos, /saude

--- GET /alunos (200) ---
status: 200 | content-type: application/json; charset=utf-8
corpo: {"total":3,"dados":[{"id":1,"nm_aluno":"Ana","turma":3},{"id":2,"nm_aluno":"Bruno","turma":3},{"id":3,"nm_aluno":"Carla","turma":4}]}

--- GET /nao-existe (404) ---
status: 404
corpo: {"erro":"rota nao encontrada","caminho":"/nao-existe","metodo":"GET"}

--- DELETE /alunos (405, o caminho existe) ---
status: 405 | header Allow: GET, POST
corpo: {"erro":"metodo nao permitido","permitido":["GET","POST"]}

--- POST /alunos sem nm_aluno (400) ---
status: 400
corpo: {"erro":"nm_aluno e obrigatorio"}

--- a rota POST chamada direto, com o corpo na mao ---
status: 201 (201 = criou; 200 seria "deu certo, sem criar nada")
corpo: {"id":4,"nm_aluno":"Diego"}
sem nm_aluno -> status: 400 | corpo: {"erro":"nm_aluno e obrigatorio"}

e o `Allow` da rota 405 vem da mesma fonte: GET, POST

--- os dois jeitos de mandar o status ---
res.writeHead(404, { ... }); res.end(corpo)
res.statusCode = 404; res.setHeader(...); res.end(corpo)
os dois produzem a mesma resposta; o writeHead e o caminho curto.

--- 405 e o unico status que tem header proprio ---
ele diz QUAIS metodos a rota aceita, e e o que o `Allow` acima transportou

servidor encerrado com close().