Blog

· 6 Min. Lesezeit

Einen Buffer-Klon an einem Wochenende bauen (mit Hapi als Backend)

Bau dir einen Warteschlangen-Scheduler im Stil von Buffer: Posting-Zeitfenster, eine Warteschlange, die sich von selbst füllt, und Veröffentlichung auf X, LinkedIn und Bluesky. Rund 120 Zeilen Node, Hapi übernimmt die Social-Media-Infrastruktur.

Die große Idee von Buffer ist die Warteschlange: Du legst die Zeitfenster einmal fest („werktags um 9:00, 12:30 und 17:00“) und schreibst danach nur noch. Jeder neue Beitrag rutscht ins nächste freie Zeitfenster.

Diese Idee sind ungefähr 50 Zeilen Code. Der schwere Teil eines Schedulers ist alles darunter: OAuth für jedes Netzwerk, Token-Refresh, Medienregeln, pünktliches Veröffentlichen, Wiederholungen. Genau das übernimmt Hapi. Du baust das Produkt; hapi.tools ist die Infrastruktur.

Am Ende hast du:

  • einen Editor, der gleichzeitig auf X, LinkedIn und Bluesky postet (plus Discord, Slack und Telegram)
  • Add to queue, das jeden Beitrag ins nächste freie Zeitfenster legt, in deiner Zeitzone, Sommerzeit inklusive
  • eine Ansicht der Warteschlange mit einem Remove-Button
  • einen Passwortschutz, damit du die App mit deinem Team oder deinen Kunden teilen kannst

Eine Datei, Node 22 oder neuer, keine Abhängigkeiten.

1. Hapi einrichten

  1. Starte eine Hapi-Testphase (15 Tage für 5 $).
  2. Verbinde deine Konten unter Accounts: X und LinkedIn mit einem Klick (OAuth), Bluesky mit einem App-Passwort.
  3. Erstelle unter API keys einen API-Key und halt ihn griffbereit.

2. Die Logik der Warteschlange

Dein Posting-Plan sind nur Daten:

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

Um einen Beitrag zu platzieren, brauchst du zwei Dinge: die kommenden Zeitfenster in deiner Zeitzone und welche davon schon belegt sind. Die belegten kommen direkt von Hapi, denn jeder geplante Beitrag hat ein scheduledAt. Eine eigene Datenbank braucht der Klon also nicht.

Der heikle Punkt ist, „09:00 in New York“ in einen exakten Zeitpunkt zu verwandeln, weil sich der Offset mit der Sommerzeit ändert. Intl.DateTimeFormat schafft das ohne Bibliothek:

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. Die ganze App

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}`));

So startest du sie:

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

Schreib drei Beiträge, klick dreimal auf Add to queue, und sie reihen sich in die nächsten drei Zeitfenster ein. Entfernst du einen, füllt der nächste Beitrag, den du hinzufügst, die Lücke.

Was Hapi für dich übernommen hat

Schau dir an, was in diesen 120 Zeilen nicht steht:

  • OAuth-Flows und Token-Refresh für X und LinkedIn, und Bluesky-Sessions
  • Zeichenlimits, Medienregeln und die Prüfung pro Netzwerk (Hapi lehnt einen zu langen Beitrag ab, bevor er in der Warteschlange landet)
  • ein Job-Runner, der genau in der richtigen Minute veröffentlicht
  • Wiederholungen, und kein doppeltes Posten, wenn etwas erneut versucht wird (schick einen Idempotency-Key-Header)
  • verschlüsselte Speicherung der Zugangsdaten

Das ist die Infrastruktur. Und genau deshalb werden aus den meisten Wochenenden „Bau dir dein eigenes Buffer“ am Ende Monate.

Daraus ein echtes Produkt machen

  • Text pro Netzwerk: Übergib platformOptions für eine längere LinkedIn-Fassung und eine Bluesky-Fassung mit 300 Zeichen.
  • Bilder: Hochladen mit POST /v1/media, dann mediaIds mit dem Beitrag schicken.
  • Hinweise bei Fehlern: Registriere einen Webhook für post.failed und zeig ein rotes Badge in der Warteschlange.
  • Entwürfe und Freigaben: Anlegen mit "draft": true, freigeben mit PATCH /v1/posts/{id}.
  • KI-Texte: Lass Claude Beiträge über Hapis MCP-Server entwerfen (https://hapi.tools/mcp) und leg die guten in die Warteschlange.
  • Kunden: Mit Hapi Pro bekommt jeder Kunde ein eigenes Profil und einen darauf beschränkten Key. Dann läuft eine Warteschlange pro Kunde.

Wer es nutzen kann

So wie es hier gebaut ist, läuft der Klon auf deinem Hapi-Workspace: Es sind die Konten, die du im Hapi-Dashboard verbindest. Das passt für deine eigene Marke, dein Team oder eine Agentur, die Kundenkonten betreut. Daraus ein öffentliches SaaS zu machen, bei dem sich Fremde anmelden und eigene Konten verbinden, braucht eine Kontoverbindung über die API. Die bietet Hapi noch nicht.

Bau deinen: 15 Tage für 5 $ → Fünf Konten und 300 Beiträge: genug, um den Klon zu bauen und ihn wirklich laufen zu lassen.

Buffer ist eine Marke des Rechteinhabers. Dieses Tutorial steht in keiner Verbindung zu Buffer und wird nicht von Buffer unterstützt. „Buffer-Klon“ beschreibt den Warteschlangen-Ablauf, den es nachbaut.