Blog

· 6 min leestijd

Zo bouw je in een weekend een Buffer-kloon (met Hapi als backend)

Bouw je eigen wachtrij-planner in Buffer-stijl: postmomenten, een wachtrij die zichzelf vult, en publiceren naar X, LinkedIn en Bluesky. Ongeveer 120 regels Node, met Hapi als social-infrastructuur.

Het grote idee van Buffer is de wachtrij: je stelt één keer postmomenten in ("doordeweeks om 9:00, 12:30 en 17:00") en daarna schrijf je alleen nog. Elk nieuw bericht valt in het eerstvolgende vrije moment.

Dat idee is zo'n 50 regels code. Het lastige aan een planner is alles eronder: OAuth voor elk netwerk, tokens vernieuwen, mediaregels, op tijd publiceren, retries. Dat doet Hapi. Jij bouwt het product; hapi.tools is de infrastructuur.

Aan het eind heb je:

  • Een composer die in één keer post naar X, LinkedIn en Bluesky (plus Discord, Slack en Telegram)
  • In de wachtrij, dat elk bericht in het eerstvolgende vrije moment zet, in jouw tijdzone, zomertijd meegerekend
  • Een wachtrijweergave met annuleerknoppen
  • Wachtwoordbeveiliging, zodat je het kunt delen met je team of klanten

Het is één bestand, Node 22 of nieuwer, geen dependencies.

1. Hapi klaarzetten

  1. Start een Hapi-proefperiode (15 dagen voor $5).
  2. Koppel je accounts onder Accounts: X en LinkedIn met één klik (OAuth), Bluesky met een app-wachtwoord.
  3. Maak een API-key aan onder API keys en houd die bij de hand.

2. De wachtrijlogica

Je postschema is gewoon data:

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

Om een bericht te plaatsen heb je twee dingen nodig: de komende postmomenten in jouw tijdzone, en welke daarvan al bezet zijn. Bezette momenten komen rechtstreeks uit Hapi, want elk ingepland bericht heeft een scheduledAt. De kloon heeft dus geen eigen database nodig.

Het subtiele stuk is "09:00 in New York" omzetten naar een exact moment, want de offset verschuift met de zomertijd. Intl.DateTimeFormat kan dat zonder bibliotheek:

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)); // verschuif met de offset van de tijdzone op die datum
}

3. De hele app

js
// buffer-clone.mjs: een wachtrij-planner in Buffer-stijl, gebouwd op Hapi.
// Starten: HAPI_API_KEY=hapi_live_... APP_PASSWORD=kies-er-een 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 = maandag … 7 = zondag

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 antwoordde met ${res.status}`);
  return body;
}

// "09:00 op 2026-11-20 in TZ" → het exacte moment, zomertijd meegerekend.
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("De wachtrij zit vol voor de komende 60 dagen");
}

const esc = (s = "") => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const when = (iso) => new Date(iso).toLocaleString("nl-NL", { 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>Wachtrij</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>Wachtrij</h1>${message ? `<p><b>${esc(message)}</b></p>` : ""}
  <form method="post" action="/post">
    <textarea name="content" required placeholder="Wat wil je delen?"></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">In de wachtrij</button> <button name="action" value="now">Nu posten</button>
  </form>
  <h2>Hierna <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>Verwijderen</button></form></li>`).join("") || "<li>Je wachtrij is leeg.</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) => {
    // Eén gedeeld wachtwoord houdt de app, en je API-key, privé.
    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="wachtrij"' }).end("Wachtwoord vereist");
      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 ? `Ingepland voor ${when(scheduledAt)} ✓` : "Gepost ✓";
      } else if (req.method === "POST" && req.url.startsWith("/cancel/")) {
        await hapi(`/posts/${encodeURIComponent(req.url.slice("/cancel/".length))}`, { method: "DELETE" });
        message = "Uit de wachtrij gehaald.";
      }
    } catch (err) {
      message = `Fout: ${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(`Hapi was niet bereikbaar: ${err.message}`);
    }
  })
  .listen(PORT, () => console.log(`Je Buffer-kloon draait op http://localhost:${PORT}`));

Starten:

bash
HAPI_API_KEY=hapi_live_... APP_PASSWORD=kies-er-een QUEUE_TZ=Europe/Berlin node buffer-clone.mjs

Schrijf drie berichten, klik drie keer op In de wachtrij, en ze staan in de volgende drie momenten. Haal er één weg, en het volgende bericht dat je toevoegt vult het gat.

Wat Hapi voor je deed

Kijk naar wat er niet in die 120 regels staat:

  • OAuth-flows en token-refresh voor X en LinkedIn, en Bluesky-sessies
  • Tekenlimieten, mediaregels en validatie per netwerk (Hapi weigert een te lang bericht voordat het in de wachtrij gaat)
  • Een job-runner die op precies de juiste minuut publiceert
  • Retries, en nooit dubbel posten als iets opnieuw wordt geprobeerd (stuur een Idempotency-Key-header mee)
  • Versleutelde opslag van credentials

Dat is de infrastructuur. Daarom worden de meeste weekenden "bouw je eigen Buffer" maanden.

Maak er een echt product van

  • Tekst per netwerk: geef platformOptions mee voor een langere LinkedIn-versie en een Bluesky-versie van 300 tekens.
  • Afbeeldingen: upload met POST /v1/media en stuur mediaIds mee met het bericht.
  • Meldingen bij fouten: registreer een webhook voor post.failed en toon een rode badge in de wachtrij.
  • Concepten en goedkeuring: maak aan met "draft": true, keur goed met PATCH /v1/posts/{id}.
  • Schrijven met AI: laat Claude berichten opstellen via de MCP-server van Hapi (https://hapi.tools/mcp) en zet de goede in de wachtrij.
  • Klanten: geef op Hapi Pro elke klant een eigen profiel en een key die daartoe beperkt is, en draai één wachtrij per klant.

Wie het kan gebruiken

Zoals het hier staat, draait de kloon op jouw Hapi-workspace: de accounts zijn de accounts die je in het Hapi-dashboard koppelt. Dat is precies goed voor je eigen merk, je team, of een bureau dat accounts van klanten beheert. Er een openbare SaaS van maken, waar vreemden zich aanmelden en hun eigen accounts koppelen, vraagt om accounts koppelen via de API. Dat biedt Hapi nog niet.

Bouw de jouwe: 15 dagen voor $5 → Vijf accounts en 300 berichten: ruim genoeg om de kloon te bouwen en echt te draaien.

Buffer is een handelsmerk van de eigenaar. Deze tutorial is niet gelieerd aan Buffer en wordt er niet door ondersteund; "Buffer-kloon" beschrijft de wachtrij-workflow die hij nabouwt.