Blog

· 7 min de lectura

Cómo crear un clon de Buffer en un fin de semana (con Hapi como backend)

Crea tu propio programador al estilo de Buffer: franjas de publicación, una cola que se llena sola y envío a X, LinkedIn y Bluesky. Unas 120 líneas de Node, con Hapi como infraestructura social.

La idea central de Buffer es la cola: defines las franjas una vez («entre semana a las 9:00, 12:30 y 17:00») y a partir de ahí solo escribes. Cada publicación nueva cae en el siguiente hueco libre.

Esa idea son unas 50 líneas de código. Lo difícil de una herramienta así es todo lo que hay debajo: OAuth en cada red, renovación de tokens, reglas para imágenes y vídeo, publicar a la hora y reintentos. De eso se encarga Hapi. Tú construyes el producto; hapi.tools es la infraestructura.

Al terminar tendrás:

  • Un editor que publica a la vez en X, LinkedIn y Bluesky (y también en Discord, Slack y Telegram)
  • Añadir a la cola, que coloca cada publicación en la siguiente franja libre, en tu zona horaria, con el horario de verano incluido
  • Una vista de la cola con botones para quitar publicaciones
  • Protección con contraseña para compartirla con tu equipo o con clientes

Es un solo archivo, Node 22 o posterior, sin dependencias.

1. Configura Hapi

  1. Empieza una prueba de Hapi (15 días por 5 USD).
  2. Conecta tus cuentas en Accounts: X y LinkedIn con un clic (OAuth) y Bluesky con una contraseña de aplicación.
  3. Crea una clave en API keys y tenla a mano.

2. La lógica de la cola

El calendario de publicación es solo datos:

js
const SLOTS = { days: [1, 2, 3, 4, 5], times: ["09:00", "12:30", "17:00"] }; // lun–vie

Para colocar una publicación necesitas dos cosas: las próximas franjas en tu zona horaria y cuáles ya están ocupadas. Las ocupadas salen directamente de Hapi, porque cada publicación programada tiene un scheduledAt. Así el clon no necesita su propia base de datos.

El único detalle delicado es convertir «las 09:00 en Nueva York» en un instante exacto, porque el desfase cambia con el horario de verano. Intl.DateTimeFormat puede hacerlo sin librerías:

js
function zonedTime(y, m, d, hh, mm) {
  const guess = Date.UTC(y, m - 1, d, hh, mm);
  const p = Object.fromEntries(
    new Intl.DateTimeFormat("en-US", { timeZone: TZ, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
      .formatToParts(new Date(guess))
      .map((x) => [x.type, x.value]),
  );
  const shownAs = Date.UTC(+p.year, p.month - 1, +p.day, +p.hour, +p.minute);
  return new Date(guess - (shownAs - guess)); // desplaza según el desfase de la zona ese día
}

3. La aplicación completa

js
// buffer-clone.mjs: una cola al estilo de Buffer, construida sobre Hapi.
// Ejecuta: HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one node buffer-clone.mjs
import http from "node:http";

const API = process.env.HAPI_API_BASE ?? "https://hapi.tools/v1";
const { HAPI_API_KEY, APP_PASSWORD, PORT = 3000 } = process.env;
const TZ = process.env.QUEUE_TZ ?? "America/New_York";
const SLOTS = { days: [1, 2, 3, 4, 5], times: ["09:00", "12:30", "17:00"] }; // 1 = lunes … 7 = domingo

async function hapi(path, options = {}) {
  const res = await fetch(API + path, {
    ...options,
    headers: { Authorization: `Bearer ${HAPI_API_KEY}`, "Content-Type": "application/json" },
  });
  if (res.status === 204) return null;
  const body = await res.json();
  if (!res.ok) throw new Error(body.error?.message ?? `Hapi returned ${res.status}`);
  return body;
}

// "09:00 del 2026-11-20 en TZ" → el instante exacto, con horario de verano incluido.
function zonedTime(y, m, d, hh, mm) {
  const guess = Date.UTC(y, m - 1, d, hh, mm);
  const p = Object.fromEntries(
    new Intl.DateTimeFormat("en-US", { timeZone: TZ, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
      .formatToParts(new Date(guess))
      .map((x) => [x.type, x.value]),
  );
  const shownAs = Date.UTC(+p.year, p.month - 1, +p.day, +p.hour, +p.minute);
  return new Date(guess - (shownAs - guess));
}

function* upcomingSlots(after) {
  const [y, m, d] = new Intl.DateTimeFormat("en-CA", { timeZone: TZ }).format(after).split("-").map(Number);
  for (let i = 0; i < 60; i++) {
    const day = new Date(Date.UTC(y, m - 1, d + i));
    if (!SLOTS.days.includes(day.getUTCDay() || 7)) continue;
    for (const time of SLOTS.times) {
      const [hh, mm] = time.split(":").map(Number);
      const at = zonedTime(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), hh, mm);
      if (at > after) yield at;
    }
  }
}

async function nextFreeSlot() {
  const { data } = await hapi("/posts?status=scheduled&limit=100");
  const taken = new Set(data.map((p) => new Date(p.scheduledAt).getTime()));
  for (const at of upcomingSlots(new Date(Date.now() + 60_000))) {
    if (!taken.has(at.getTime())) return at;
  }
  throw new Error("The queue is full for the next 60 days");
}

const esc = (s = "") => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const when = (iso) => new Date(iso).toLocaleString("en-US", { timeZone: TZ, weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });

async function page(message = "") {
  const [{ data: accounts }, { data: queued }] = await Promise.all([hapi("/accounts"), hapi("/posts?status=scheduled&limit=100")]);
  queued.sort((a, b) => new Date(a.scheduledAt) - new Date(b.scheduledAt));
  return `<!doctype html><meta charset="utf-8"><title>Queue</title>
  <style>body{font:16px system-ui;max-width:640px;margin:40px auto;padding:0 16px}textarea{width:100%;height:110px}li{margin:10px 0}small{color:#666}</style>
  <h1>Queue</h1>${message ? `<p><b>${esc(message)}</b></p>` : ""}
  <form method="post" action="/post">
    <textarea name="content" required placeholder="What do you want to share?"></textarea>
    <p>${accounts.map((a) => `<label><input type="checkbox" name="accountId" value="${esc(a.id)}" checked> ${esc(a.platform)} · ${esc(a.username ?? a.displayName)}</label>`).join("<br>")}</p>
    <button name="action" value="queue">Add to queue</button> <button name="action" value="now">Post now</button>
  </form>
  <h2>Up next <small>(${esc(TZ)})</small></h2>
  <ul>${queued.map((p) => `<li><small>${esc(when(p.scheduledAt))}</small><br>${esc(p.content)}
    <form method="post" action="/cancel/${esc(p.id)}" style="display:inline"><button>Remove</button></form></li>`).join("") || "<li>Your queue is empty.</li>"}</ul>`;
}

function readBody(req) {
  return new Promise((resolve) => {
    let data = "";
    req.on("data", (chunk) => (data += chunk));
    req.on("end", () => resolve(data));
  });
}

http
  .createServer(async (req, res) => {
    // Una contraseña compartida mantiene privadas la app y tu clave de API.
    const encoded = (req.headers.authorization ?? "").split(" ")[1] ?? "";
    const password = Buffer.from(encoded, "base64").toString().split(":").slice(1).join(":");
    if (!APP_PASSWORD || password !== APP_PASSWORD) {
      res.writeHead(401, { "WWW-Authenticate": 'Basic realm="queue"' }).end("Password required");
      return;
    }
    let message = "";
    try {
      if (req.method === "POST" && req.url === "/post") {
        const form = new URLSearchParams(await readBody(req));
        const scheduledAt = form.get("action") === "now" ? null : (await nextFreeSlot()).toISOString();
        await hapi("/posts", {
          method: "POST",
          body: JSON.stringify({ content: form.get("content"), accountIds: form.getAll("accountId"), scheduledAt }),
        });
        message = scheduledAt ? `Queued for ${when(scheduledAt)} ✓` : "Posted ✓";
      } else if (req.method === "POST" && req.url.startsWith("/cancel/")) {
        await hapi(`/posts/${encodeURIComponent(req.url.slice("/cancel/".length))}`, { method: "DELETE" });
        message = "Removed from the queue.";
      }
    } catch (err) {
      message = `Error: ${err.message}`;
    }
    try {
      const html = await page(message);
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(html);
    } catch (err) {
      res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }).end(`Couldn't reach Hapi: ${err.message}`);
    }
  })
  .listen(PORT, () => console.log(`Your Buffer clone is on http://localhost:${PORT}`));

Ejecútalo:

bash
HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one QUEUE_TZ=Europe/Berlin node buffer-clone.mjs

Escribe tres publicaciones, pulsa Add to queue tres veces y se colocan en las tres franjas siguientes. Quita una y la próxima que añadas ocupa ese hueco.

Lo que Hapi resolvió por ti

Fíjate en lo que no está en esas 120 líneas:

  • Los flujos de OAuth y la renovación de tokens de X y LinkedIn, y las sesiones de Bluesky
  • Límites de caracteres, reglas de medios y validación por red (Hapi rechaza una publicación demasiado larga antes de ponerla en la cola)
  • Un proceso que publica en el minuto exacto
  • Reintentos, sin publicar dos veces cuando algo se reintenta (envía un encabezado Idempotency-Key)
  • El almacenamiento cifrado de las credenciales

Eso es la infraestructura. También explica por qué la mayoría de los fines de semana de «haz tu propio Buffer» acaban durando meses.

Conviértelo en un producto de verdad

  • Texto distinto por red: pasa platformOptions para una versión más larga en LinkedIn y una de 300 caracteres en Bluesky.
  • Imágenes: súbelas con POST /v1/media y envía mediaIds con la publicación.
  • Avisos de fallo: registra un webhook de post.failed y muestra un indicador rojo en la cola.
  • Borradores y aprobaciones: crea la publicación con "draft": true y apruébala con PATCH /v1/posts/{id}.
  • Escritura con IA: deja que Claude redacte publicaciones a través del servidor MCP de Hapi (https://hapi.tools/mcp) y mete las que te convenzan en la cola.
  • Clientes: en Hapi Pro, dale a cada cliente su propio perfil y una clave limitada a ese perfil; luego ejecuta una cola por cliente.

Quién puede usarlo

Tal como está montado aquí, el clon funciona en tu espacio de trabajo de Hapi: las cuentas son las que conectas en el panel. Sirve para tu propia marca, para tu equipo o para una agencia que lleva las cuentas de sus clientes. Convertirlo en un SaaS público, donde cualquiera se registra y conecta sus propias cuentas, exige conectar cuentas a través de la API, y eso Hapi todavía no lo ofrece.

Crea el tuyo: 15 días por 5 USD → Cinco cuentas y 300 publicaciones: de sobra para construir el clon y usarlo de verdad.

Buffer es una marca registrada de su propietario. Este tutorial no está afiliado a Buffer ni cuenta con su respaldo; «clon de Buffer» describe el flujo de cola que reproduce.