· 7 min de lecture
Créer un clone de Buffer en un week-end (avec Hapi comme infrastructure)
Construisez votre propre planificateur à file d’attente, dans l’esprit de Buffer : des créneaux, une file qui se remplit toute seule, et la publication vers X, LinkedIn et Bluesky. Environ 120 lignes de Node, avec Hapi comme infrastructure sociale.
La grande idée de Buffer, c’est la file : vous réglez les créneaux une fois (« en semaine à 9 h, 12 h 30 et 17 h »), puis vous écrivez. Chaque nouveau post tombe dans le prochain créneau libre.
Cette idée tient en une cinquantaine de lignes. Le difficile, dans un planificateur, c’est tout ce qu’il y a dessous : OAuth pour chaque réseau, rafraîchissement des tokens, règles sur les médias, publication à l’heure, relances. C’est le travail de Hapi. Vous construisez le produit ; hapi.tools est l’infrastructure.
À la fin, vous aurez :
- Un compositeur qui publie d’un coup sur X, LinkedIn et Bluesky (et aussi Discord, Slack et Telegram)
- Add to queue, qui place chaque post dans le prochain créneau libre, dans votre fuseau, changement d’heure compris
- Une vue de la file, avec des boutons pour retirer un post
- Une protection par mot de passe, pour le partager avec votre équipe ou vos clients
Un seul fichier, Node 22 ou plus récent, aucune dépendance.
1. Préparer Hapi
- Démarrez un essai Hapi (15 jours pour 5 $).
- Connectez vos comptes dans Accounts : X et LinkedIn en un clic (OAuth), Bluesky avec un mot de passe d’application.
- Créez une clé sous API keys et gardez-la sous la main.
2. La logique de la file
Le calendrier de publication n’est que de la donnée :
const SLOTS = { days: [1, 2, 3, 4, 5], times: ["09:00", "12:30", "17:00"] }; // Mon–FriPour placer un post, il faut deux choses : les prochains créneaux dans votre fuseau, et ceux qui sont déjà pris. Les créneaux pris viennent directement de Hapi, puisque chaque post planifié a un scheduledAt. Le clone n’a donc pas besoin de sa propre base.
Le point délicat, c’est de transformer « 9 h 00 à New York » en un instant exact : le décalage change avec l’heure d’été. Intl.DateTimeFormat suffit, sans bibliothèque :
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’application entière
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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}`));Lancez-la :
HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one QUEUE_TZ=Europe/Berlin node buffer-clone.mjsRédigez trois posts, cliquez trois fois sur Add to queue : ils s’alignent sur les trois prochains créneaux. Retirez-en un, et le post suivant que vous ajoutez comble le trou.
Ce que Hapi a pris en charge
Regardez ce qui n’est pas dans ces 120 lignes :
- Les flux OAuth et le rafraîchissement des tokens pour X et LinkedIn, et les sessions Bluesky
- Les limites de caractères, les règles sur les médias et la validation par réseau (Hapi refuse un post trop long avant qu’il n’entre dans la file)
- Un exécuteur de tâches qui publie à la minute près
- Les relances, et l’absence de double publication quand quelque chose est réessayé (envoyez un en-tête
Idempotency-Key) - Le stockage chiffré des identifiants
C’est l’infrastructure. C’est aussi pour ça que la plupart des week-ends « je me fais mon Buffer » finissent en mois.
En faire un vrai produit
- Un texte par réseau : passez
platformOptionspour une version LinkedIn plus longue et une version Bluesky de 300 caractères. - Des images : envoyez-les avec
POST /v1/media, puis joignezmediaIdsau post. - Des alertes d’échec : enregistrez un webhook sur
post.failedet affichez un badge rouge dans la file. - Brouillons et validations : créez avec
"draft": true, validez avecPATCH /v1/posts/{id}. - De l’écriture assistée : laissez Claude rédiger via le serveur MCP de Hapi (
https://hapi.tools/mcp), et déposez les bons textes dans la file. - Des clients : sur Hapi Pro, donnez à chaque client son profil et une clé limitée à ce profil, puis une file par client.
Qui peut s’en servir
Tel qu’il est construit ici, le clone tourne sur votre espace de travail Hapi : les comptes sont ceux que vous connectez dans le tableau de bord. C’est exactement ce qu’il faut pour votre marque, votre équipe, ou une agence qui gère les comptes de ses clients. En faire un SaaS public, où des inconnus s’inscrivent et connectent leurs propres comptes, demande une connexion de comptes via l’API, que Hapi ne propose pas encore.
Construisez le vôtre : 15 jours pour 5 $ → Cinq comptes et 300 publications : assez pour construire le clone et le faire tourner pour de vrai.
Buffer est une marque de son propriétaire. Ce tutoriel n’est ni affilié à Buffer ni approuvé par Buffer. « Clone de Buffer » décrit le fonctionnement de planification par file que ce guide recrée.