· 5분 분량
주말에 Buffer 클론 만들기 (백엔드는 Hapi)
Buffer처럼 게시 슬롯과 스스로 채워지는 큐를 만들고, X, LinkedIn, Bluesky에 게시하세요. Node 약 120줄이면 되고, 소셜 인프라는 Hapi가 맡습니다.
Buffer의 핵심은 큐입니다. 게시 슬롯을 한 번만 정해 두면(“평일 9:00, 12:30, 17:00”) 그다음부터는 글만 쓰면 됩니다. 새 글은 비어 있는 다음 슬롯에 들어갑니다.
그 아이디어 자체는 코드 50줄 정도입니다. 스케줄러에서 어려운 부분은 그 아래에 있습니다. 네트워크마다 OAuth, 토큰 갱신, 미디어 규칙, 정시 게시, 재시도. 그 일을 Hapi가 합니다. 제품은 여러분이 만들고, 그 밑의 인프라는 hapi.tools입니다.
끝나면 이런 것이 갖춰집니다.
- X, LinkedIn, Bluesky에 한 번에 게시하는 작성 화면. Discord, Slack, Telegram도 함께
- Add to queue. 글을 내 시간대의 다음 빈 슬롯에 넣으며, 서머타임도 반영합니다
- 빼기 버튼이 있는 큐 화면
- 팀이나 클라이언트와 나눠 쓸 수 있는 비밀번호 보호
파일 하나, Node 22 이상, 의존성 없음.
1. Hapi 준비
- Hapi 체험을 시작하세요(15일 $5).
- Accounts에서 계정을 연결하세요. X와 LinkedIn은 클릭 한 번(OAuth)이고, Bluesky는 앱 비밀번호입니다.
- API keys에서 API 키를 만들고 가까이 두세요.
2. 큐 로직
게시 일정은 그냥 데이터입니다.
const SLOTS = { days: [1, 2, 3, 4, 5], times: ["09:00", "12:30", "17:00"] }; // Mon–Fri글을 넣으려면 두 가지가 필요합니다. 내 시간대에서 다가오는 슬롯 시각, 그리고 그중 이미 찬 슬롯. 찬 슬롯은 Hapi에서 바로 가져옵니다. 예약된 게시물마다 scheduledAt이 있기 때문입니다. 그래서 이 클론에는 자체 데이터베이스가 필요 없습니다.
까다로운 부분은 “뉴욕 기준 09:00”을 정확한 시각으로 바꾸는 일입니다. 서머타임 때문에 시차가 바뀌기 때문입니다. 라이브러리 없이 Intl.DateTimeFormat으로 처리할 수 있습니다.
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. 앱 전체
// 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}`));실행합니다.
HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one QUEUE_TZ=Europe/Berlin node buffer-clone.mjs글 세 개를 쓰고 Add to queue를 세 번 누르면, 다음 세 슬롯에 차례로 들어갑니다. 하나를 빼면, 다음에 추가하는 글이 그 빈자리를 채웁니다.
Hapi가 대신 처리한 것
이 120줄에 없는 것을 보세요.
- X와 LinkedIn의 OAuth와 토큰 갱신, 그리고 Bluesky 세션
- 글자 수 제한, 미디어 규칙, 네트워크별 검증. 너무 긴 글은 큐에 들어가기 전에 Hapi가 거절합니다
- 정확한 분에 게시하는 작업 실행기
- 재시도, 그리고 재시도해도 같은 글이 두 번 나가지 않는 처리(
Idempotency-Key헤더) - 암호화된 자격 증명 저장
이게 인프라입니다. “Buffer를 직접 만들자”던 주말이 몇 달이 되는 이유도 여기 있습니다.
진짜 제품으로 만들기
- 네트워크별 문구.
platformOptions로 LinkedIn용 긴 글과 Bluesky용 300자 글을 넘기세요. - 이미지.
POST /v1/media로 업로드한 뒤, 게시물에mediaIds를 넣으세요. - 실패 알림.
post.failed웹훅을 등록하고 큐에 빨간 배지를 띄우세요. - 초안과 승인.
"draft": true로 만들고,PATCH /v1/posts/{id}로 승인하세요. - AI 작성. Hapi MCP 서버(
https://hapi.tools/mcp)로 Claude가 초안을 쓰게 하고, 괜찮은 글을 큐에 넣으세요. - 클라이언트. Hapi Pro에서는 클라이언트마다 프로필과 그 프로필로 제한된 키를 주고, 큐도 클라이언트마다 하나씩 돌리세요.
누가 쓸 수 있나
여기서 만든 클론은 여러분의 Hapi 워크스페이스에서 돌아갑니다. 계정은 Hapi 대시보드에서 연결한 계정입니다. 내 브랜드, 우리 팀, 또는 클라이언트 계정을 운영하는 에이전시에 맞습니다. 낯선 사람이 가입해서 자기 계정을 연결하는 공개 SaaS로 만들려면 API로 계정을 연결할 수 있어야 하는데, Hapi는 아직 그 기능을 제공하지 않습니다.
직접 만들어 보세요. 15일 $5 → 계정 5개와 게시물 300개면, 클론을 만들고 실제로 돌리기에 충분합니다.
Buffer는 해당 소유자의 상표입니다. 이 튜토리얼은 Buffer와 제휴하거나 Buffer의 승인을 받은 것이 아니며, “Buffer 클론”은 재현하는 큐 예약 방식을 가리킵니다.