Blog

· 6 min di lettura

Come costruire un clone di Buffer in un weekend (con Hapi come backend)

Costruisci il tuo scheduler a coda, nello stile di Buffer: slot di pubblicazione, una coda che si riempie da sola e l’invio su X, LinkedIn e Bluesky. Circa 120 righe di Node, con Hapi come infrastruttura social.

L’idea forte di Buffer è la coda: imposti gli slot una volta sola (“nei feriali alle 9:00, alle 12:30 e alle 17:00”) e poi scrivi e basta. Ogni nuovo post finisce nel primo slot libero.

Quell’idea sta in una cinquantina di righe. La parte difficile di uno scheduler è tutto quello che c’è sotto: OAuth per ogni network, refresh dei token, regole sui media, pubblicazione puntuale, retry. È quello che fa Hapi. Tu costruisci il prodotto; hapi.tools è l’infrastruttura.

Alla fine avrai:

  • Un editor che pubblica su X, LinkedIn e Bluesky (e anche su Discord, Slack e Telegram) in un colpo solo
  • Add to queue, che mette ogni post nel prossimo slot libero, nel tuo fuso orario, ora legale compresa
  • Una vista della coda, con i pulsanti per annullare
  • Una password, così puoi condividerlo con il team o con i clienti

È un solo file, Node 22 o successivo, nessuna dipendenza.

1. Configura Hapi

  1. Inizia una prova di Hapi (15 giorni a 5 $).
  2. Collega gli account sotto Accounts: X e LinkedIn con un clic (OAuth), Bluesky con una password per app.
  3. Crea una chiave API sotto API keys e tienila a portata di mano.

2. La logica della coda

Il calendario di pubblicazione è solo dati:

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

Per piazzare un post ti servono due cose: i prossimi slot nel tuo fuso orario, e quali sono già occupati. Gli slot occupati arrivano direttamente da Hapi, perché ogni post programmato ha un scheduledAt. Il clone non ha bisogno di un database suo.

Il punto delicato è trasformare “le 09:00 a New York” in un istante preciso, perché l’offset cambia con l’ora legale. Intl.DateTimeFormat lo fa senza librerie:

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)); // shift by the zone's offset on that date
}

3. L’app completa

js
// buffer-clone.mjs: a Buffer-style queue scheduler built on Hapi.
// Run: 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 = Monday … 7 = Sunday

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 on 2026-11-20 in TZ" → the exact moment, daylight saving included.
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) => {
    // One shared password keeps the app, and your API key, private.
    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}`));

Avvialo:

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

Scrivi tre post, premi Add to queue tre volte, e si mettono in fila nei tre slot successivi. Togline uno, e il prossimo post che aggiungi riempie il buco.

Cosa ha gestito Hapi al posto tuo

Guarda cosa non c’è in quelle 120 righe:

  • I flussi OAuth e il refresh dei token per X e LinkedIn, e le sessioni Bluesky
  • Limiti di caratteri, regole sui media e validazione per ogni network (Hapi rifiuta un post troppo lungo prima che entri in coda)
  • Un job che pubblica al minuto esatto
  • I retry, e il fatto di non pubblicare due volte quando qualcosa viene ritentato (invia un header Idempotency-Key)
  • La conservazione cifrata delle credenziali

Questa è l’infrastruttura. Ed è anche il motivo per cui un weekend passato a ricostruire Buffer, quasi sempre, diventa qualche mese.

Farne un prodotto vero

  • Un testo per network: passa platformOptions per una versione più lunga su LinkedIn e una da 300 caratteri su Bluesky.
  • Immagini: caricale con POST /v1/media, poi invia mediaIds insieme al post.
  • Avvisi di errore: registra un webhook per post.failed e mostra un badge rosso nella coda.
  • Bozze e approvazioni: crea con "draft": true e approva con PATCH /v1/posts/{id}.
  • Scrittura con l’AI: fai scrivere le bozze a Claude tramite il server MCP di Hapi (https://hapi.tools/mcp) e metti in coda quelle che funzionano.
  • Clienti: con Hapi Pro dai a ogni cliente un profilo suo e una chiave limitata a quel profilo, poi fai girare una coda per cliente.

Chi può usarlo

Così com’è, il clone gira sul tuo workspace Hapi: gli account sono quelli che colleghi nella dashboard di Hapi. Va benissimo per il tuo brand, per il team o per un’agenzia che gestisce gli account dei clienti. Trasformarlo in un SaaS pubblico, dove degli sconosciuti si registrano e collegano i propri account, richiede la connessione degli account tramite API, che Hapi non offre ancora.

Costruisci il tuo: 15 giorni a 5 $ → Cinque account e 300 post: abbastanza per costruire il clone e usarlo davvero.

Buffer è un marchio del suo proprietario. Questo tutorial non è affiliato a Buffer né approvato da Buffer; “clone di Buffer” descrive il flusso di programmazione a coda che ricostruisce.