Something went wrong. Try again.
a little carrier pigeon that ferries figma events to discord
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179/** * Batcher Durable Object. * * One instance per Figma file_key (via idFromName). Coalesces LIBRARY_PUBLISH * events that stream in per asset type (components/styles/variables) into a * single Discord message. * * Flow: * 1. /ingest receives a merged event + the Discord webhook URL. * 2. Items are merged (dedup by key) into persistent state. * 3. An alarm is scheduled for BATCH_WINDOW_MS after the first event. * 4. alarm() builds one embed and POSTs it to Discord. * 5. On Discord failure: one retry at +RETRY_DELAY_MS, then drop. */
import type { BatchItems, BatchState, Env, LibraryItem } from "./types.js";import { buildEmbed, sendToDiscord } from "./discord.js";import type { LibraryPublishEvent } from "./figma.js";import { collectChanged } from "./figma.js";
const BATCH_WINDOW_MS = 60_000;const RETRY_DELAY_MS = 5 * 60_000;const MAX_FLUSH_ATTEMPTS = 2; // initial + one retry
const STATE_KEY = "batch";
interface IngestRequest { event: LibraryPublishEvent; discordWebhookUrl: string;}
function emptyItems(): BatchItems { return { components: {}, styles: {}, variables: {} };}
function mergeInto( target: Record<string, LibraryItem>, incoming: LibraryItem[],): void { for (const item of incoming) { // Later name wins; dedup is by key. target[item.key] = item; }}
export class Batcher implements DurableObject { constructor(private readonly state: DurableObjectState, _env: Env) {}
async fetch(request: Request): Promise<Response> { const url = new URL(request.url); if (request.method === "POST" && url.pathname === "/ingest") { return this.ingest(request); } return new Response("not found", { status: 404 }); }
private async ingest(request: Request): Promise<Response> { const body = (await request.json()) as IngestRequest; const { event, discordWebhookUrl } = body;
const existing = (await this.state.storage.get<BatchState>(STATE_KEY)) ?? null;
const changed = collectChanged(event);
const now = Date.now(); const batch: BatchState = existing ?? { fileKey: event.file_key, fileName: event.file_name, fileDescription: event.description ?? "", firstSeenAt: now, items: emptyItems(), discordWebhookUrl, flushAttempts: 0, };
// If we already had a batch, refresh mutable metadata from the latest event. batch.fileName = event.file_name; if (event.description && event.description.trim().length > 0) { batch.fileDescription = event.description; } // Always keep the most recent webhook URL (in case operator updated KV). batch.discordWebhookUrl = discordWebhookUrl;
mergeInto(batch.items.components, changed.components); mergeInto(batch.items.styles, changed.styles); mergeInto(batch.items.variables, changed.variables);
await this.state.storage.put(STATE_KEY, batch);
// Schedule a flush if none is already pending. Self-heal past alarms // (e.g. if a prior alarm() threw before clearing state) by treating a // stale alarm as absent. const currentAlarm = await this.state.storage.getAlarm(); if (currentAlarm === null || currentAlarm < now) { await this.state.storage.setAlarm(now + BATCH_WINDOW_MS); }
return new Response("ok", { status: 200 }); }
async alarm(): Promise<void> { const batch = await this.state.storage.get<BatchState>(STATE_KEY); if (!batch) return;
batch.flushAttempts += 1;
const componentsCount = Object.keys(batch.items.components).length; const stylesCount = Object.keys(batch.items.styles).length; const variablesCount = Object.keys(batch.items.variables).length; const totalItems = componentsCount + stylesCount + variablesCount;
// Nothing changed (deletion-only publish, or cleared upstream) -> drop. if (totalItems === 0) { console.log( `no changed items for ${batch.fileKey}; dropping (likely deletion-only publish)`, ); await this.state.storage.delete(STATE_KEY); return; }
const embed = buildEmbed(batch);
let success = false; let res: Response | undefined; try { res = await sendToDiscord(batch.discordWebhookUrl, embed); success = res.ok; if (!success) { const body = await res.text().catch(() => "<unreadable>"); console.error( `Discord POST failed for ${batch.fileKey}: ${res.status} ${res.statusText} body=${body.slice(0, 500)}`, ); } } catch (err) { console.error(`Discord POST threw for ${batch.fileKey}:`, err); }
if (success) { await this.state.storage.delete(STATE_KEY); return; }
// Dead webhook -> retrying won't help. Drop immediately so the operator // is the bottleneck (they need to update the KV mapping), not our retry. if (res?.status === 401 || res?.status === 404) { console.error( `Discord webhook dead for ${batch.fileKey} (${res.status}); dropping without retry. Update KV with new URL.`, ); await this.state.storage.delete(STATE_KEY); return; }
// Retry budget exhausted -> drop the batch to avoid a loop. if (batch.flushAttempts >= MAX_FLUSH_ATTEMPTS) { console.error( `Dropping batch for ${batch.fileKey} after ${batch.flushAttempts} failed attempts`, ); await this.state.storage.delete(STATE_KEY); return; }
// On 429, honor Retry-After (clamped to a sane [60s, 10min] range). // All other failures use the fixed RETRY_DELAY_MS. let retryDelayMs = RETRY_DELAY_MS; if (res?.status === 429) { const retryAfter = res.headers.get("retry-after"); const secs = retryAfter ? Number.parseFloat(retryAfter) : NaN; if (Number.isFinite(secs) && secs > 0) { retryDelayMs = Math.min(Math.max(secs * 1000, 60_000), 10 * 60_000); } }
// Schedule a single retry. await this.state.storage.put(STATE_KEY, batch); await this.state.storage.setAlarm(Date.now() + retryDelayMs); }}