Skip to content

Servidor HTTP nativo ​

Antes de usar Express, vale la pena construir un servidor sin frameworks con el módulo http. Entenderás qué es una petición, una respuesta, los headers y los códigos de estado. Todo lo que hace Express por debajo.

Concepto ​

La web funciona con el protocolo HTTP: un cliente (navegador, app, Postman) envía una petición (request) y el servidor responde con una respuesta (response).

txt
Cliente                                  Servidor
  │  GET /productos HTTP/1.1                │
  │  Host: localhost:3000                   │
  │  Accept: application/json  ───────────► │
  │                                         │ procesa
  │  HTTP/1.1 200 OK                        │
  │  Content-Type: application/json         │
  │ ◄───────────  [{"id":1,...}]            │

Una petición tiene:

  • Método: qué quieres hacer (GET leer, POST crear, PUT/PATCH actualizar, DELETE borrar).
  • URL: ruta y parámetros (/productos?categoria=ropa).
  • Headers: metadatos (tipo de contenido, autenticación, idioma).
  • Body: datos enviados (en POST, PUT, PATCH).

Una respuesta tiene:

  • Código de estado: 200 OK, 201 creado, 404 no encontrado, 500 error del servidor.
  • Headers: Content-Type, cookies, caché.
  • Body: HTML, JSON, una imagen...

Ejemplo: el servidor más simple ​

js
// servidor.js
import http from 'node:http'

const servidor = http.createServer((req, res) => {
  // req: la petición (Readable stream)
  // res: la respuesta (Writable stream)
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/plain; charset=utf-8')
  res.end('¡Hola desde Node! 👋')
})

const PUERTO = 3000
servidor.listen(PUERTO, () => {
  console.log(`Servidor en http://localhost:${PUERTO}`)
})
bash
node --watch servidor.js

Abre http://localhost:3000 en el navegador o usa curl:

bash
curl -i http://localhost:3000

Rutas y métodos ​

js
// rutas.js
import http from 'node:http'

const servidor = http.createServer((req, res) => {
  // new URL separa ruta y parámetros de consulta
  const url = new URL(req.url, `http://${req.headers.host}`)
  const { pathname, searchParams } = url

  console.log(`${req.method} ${pathname}`)

  if (req.method === 'GET' && pathname === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
    return res.end('<h1>Inicio</h1><a href="/saludo?nombre=Ana">Saludar</a>')
  }

  if (req.method === 'GET' && pathname === '/saludo') {
    const nombre = searchParams.get('nombre') ?? 'visitante'
    res.writeHead(200, { 'Content-Type': 'application/json' })
    return res.end(JSON.stringify({ mensaje: `Hola, ${nombre}` }))
  }

  res.writeHead(404, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify({ error: 'Ruta no encontrada' }))
})

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

Leer el body de una petición ​

El body llega como stream, en chunks. Hay que juntarlos:

js
// Función auxiliar para leer JSON del body
async function leerJson(req) {
  const chunks = []
  for await (const chunk of req) {
    chunks.push(chunk)
  }
  const texto = Buffer.concat(chunks).toString('utf8')
  return texto ? JSON.parse(texto) : {}
}

Caso real: mini API REST sin frameworks ​

js
// api.js
import http from 'node:http'

// "Base de datos" en memoria
let productos = [
  { id: 1, nombre: 'Teclado', precio: 899 },
  { id: 2, nombre: 'Mouse', precio: 349 }
]
let siguienteId = 3

function enviarJson(res, status, datos) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
  res.end(JSON.stringify(datos))
}

async function leerJson(req) {
  const chunks = []
  let tamaño = 0
  for await (const chunk of req) {
    tamaño += chunk.length
    if (tamaño > 1e6) throw new Error('Body demasiado grande') // límite 1 MB
    chunks.push(chunk)
  }
  const texto = Buffer.concat(chunks).toString('utf8')
  return texto ? JSON.parse(texto) : {}
}

const servidor = http.createServer(async (req, res) => {
  const { pathname } = new URL(req.url, `http://${req.headers.host}`)
  // /productos/2 → ['', 'productos', '2']
  const [, recurso, idTexto] = pathname.split('/')
  const id = Number(idTexto)

  try {
    if (recurso !== 'productos') {
      return enviarJson(res, 404, { error: 'Ruta no encontrada' })
    }

    // GET /productos
    if (req.method === 'GET' && !idTexto) {
      return enviarJson(res, 200, productos)
    }

    // GET /productos/:id
    if (req.method === 'GET' && idTexto) {
      const producto = productos.find((p) => p.id === id)
      if (!producto) return enviarJson(res, 404, { error: 'Producto no encontrado' })
      return enviarJson(res, 200, producto)
    }

    // POST /productos
    if (req.method === 'POST' && !idTexto) {
      const { nombre, precio } = await leerJson(req)
      if (typeof nombre !== 'string' || typeof precio !== 'number' || precio < 0) {
        return enviarJson(res, 400, { error: 'nombre (texto) y precio (número >= 0) son obligatorios' })
      }
      const nuevo = { id: siguienteId++, nombre, precio }
      productos.push(nuevo)
      return enviarJson(res, 201, nuevo)
    }

    // DELETE /productos/:id
    if (req.method === 'DELETE' && idTexto) {
      const antes = productos.length
      productos = productos.filter((p) => p.id !== id)
      if (productos.length === antes) return enviarJson(res, 404, { error: 'Producto no encontrado' })
      res.writeHead(204) // sin contenido
      return res.end()
    }

    enviarJson(res, 405, { error: 'Método no permitido' })
  } catch (error) {
    if (error instanceof SyntaxError) {
      return enviarJson(res, 400, { error: 'JSON inválido' })
    }
    console.error(error)
    enviarJson(res, 500, { error: 'Error interno del servidor' })
  }
})

servidor.listen(3000, () => console.log('API en http://localhost:3000/productos'))

Pruébala:

bash
curl http://localhost:3000/productos
curl http://localhost:3000/productos/1
curl -X POST http://localhost:3000/productos -H "Content-Type: application/json" -d '{"nombre":"Monitor","precio":3499}'
curl -X DELETE -i http://localhost:3000/productos/2

En PowerShell

curl es un alias distinto. Usa curl.exe o Invoke-RestMethod:

powershell
Invoke-RestMethod -Method Post -Uri http://localhost:3000/productos -ContentType "application/json" -Body '{"nombre":"Monitor","precio":3499}'

Servir archivos estáticos ​

js
// estaticos.js
import http from 'node:http'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import path from 'node:path'

const PUBLICA = path.join(import.meta.dirname, 'public')
const TIPOS = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css',
  '.js': 'text/javascript',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.svg': 'image/svg+xml'
}

http.createServer(async (req, res) => {
  const { pathname } = new URL(req.url, 'http://localhost')
  const relativa = pathname === '/' ? 'index.html' : decodeURIComponent(pathname)
  const ruta = path.join(PUBLICA, relativa)

  // Seguridad: impedir salir de la carpeta pública con ../../
  if (!ruta.startsWith(PUBLICA)) {
    res.writeHead(403)
    return res.end('Prohibido')
  }

  try {
    const info = await stat(ruta)
    if (!info.isFile()) throw new Error()
    res.writeHead(200, { 'Content-Type': TIPOS[path.extname(ruta)] ?? 'application/octet-stream' })
    createReadStream(ruta).pipe(res) // stream: memoria constante
  } catch {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
    res.end('No encontrado')
  }
}).listen(3000, () => console.log('http://localhost:3000'))

Error común

Olvidar res.end(). El navegador se queda "cargando" para siempre porque la respuesta nunca termina. Toda ruta debe terminar en res.end() (o un pipe a res).

Seguridad

Nunca construyas rutas de archivo con datos del usuario sin validar. Un atacante podría pedir /../../etc/passwd (path traversal). Valida que la ruta final siga dentro de tu carpeta.

Como ves, sin framework hay que hacer todo a mano: parsear URLs, leer el body, manejar errores. Por eso en la siguiente lección usaremos Express.

Ejercicios ​

  1. Agrega a la mini API la ruta PUT /productos/:id que actualice nombre y/o precio.
  2. Agrega soporte para GET /productos?max=500 que filtre por precio máximo.
Ver solución
js
// Dentro del try, antes de la respuesta 405:

// PUT /productos/:id
if (req.method === 'PUT' && idTexto) {
  const producto = productos.find((p) => p.id === id)
  if (!producto) return enviarJson(res, 404, { error: 'Producto no encontrado' })
  const { nombre, precio } = await leerJson(req)
  if (nombre !== undefined && typeof nombre !== 'string') {
    return enviarJson(res, 400, { error: 'nombre debe ser texto' })
  }
  if (precio !== undefined && (typeof precio !== 'number' || precio < 0)) {
    return enviarJson(res, 400, { error: 'precio debe ser número >= 0' })
  }
  if (nombre !== undefined) producto.nombre = nombre
  if (precio !== undefined) producto.precio = precio
  return enviarJson(res, 200, producto)
}
js
// Reemplaza la ruta GET /productos por:
const { pathname, searchParams } = new URL(req.url, `http://${req.headers.host}`)
// ...
if (req.method === 'GET' && !idTexto) {
  const max = Number(searchParams.get('max'))
  const resultado = searchParams.has('max') && !Number.isNaN(max)
    ? productos.filter((p) => p.precio <= max)
    : productos
  return enviarJson(res, 200, resultado)
}

Resumen ​

  • http.createServer((req, res) => ...) crea un servidor; listen(puerto) lo arranca.
  • Usa new URL(req.url, base) para separar ruta y query string.
  • El body llega como stream: júntalo con for await y conviértelo con JSON.parse.
  • Responde con el código correcto (200, 201, 204, 400, 404, 500) y el Content-Type adecuado.

Academia Web — aprende, copia y construye.