a little carrier pigeon that ferries figma events to discord
1/**
2 * Batcher Durable Object.
3 *
4 * One instance per Figma file_key (via idFromName). Coalesces LIBRARY_PUBLISH
5 * events that stream in per asset type (components/styles/variables) into a
6 * single Discord message.
7 *
8 * Flow:
9 * 1. /ingest receives a merged event + the Discord webhook URL.
10 * 2. Items are merged (dedup by key) into persistent state.
11 * 3. An alarm is scheduled for BATCH_WINDOW_MS after the first event.
12 * 4. alarm() builds one embed and POSTs it to Discord.
13 * 5. On Discord failure: one retry at +RETRY_DELAY_MS, then drop.
14 */
15
16import type { BatchItems, BatchState, Env, LibraryItem } from "./types.js";
17import { buildEmbed, sendToDiscord } from "./discord.js";
18import type { LibraryPublishEvent } from "./figma.js";
19import { collectChanged } from "./figma.js";
20
21const BATCH_WINDOW_MS = 60_000;
22const RETRY_DELAY_MS = 5 * 60_000;
23const MAX_FLUSH_ATTEMPTS = 2; // initial + one retry
24
25const STATE_KEY = "batch";
26
27interface IngestRequest {
28 event: LibraryPublishEvent;
29 discordWebhookUrl: string;
30}
31
32function emptyItems(): BatchItems {
33 return { components: {}, styles: {}, variables: {} };
34}
35
36function mergeInto(
37 target: Record<string, LibraryItem>,
38 incoming: LibraryItem[],
39): void {
40 for (const item of incoming) {
41 // Later name wins; dedup is by key.
42 target[item.key] = item;
43 }
44}
45
46export class Batcher implements DurableObject {
47 constructor(private readonly state: DurableObjectState, _env: Env) {}
48
49 async fetch(request: Request): Promise<Response> {
50 const url = new URL(request.url);
51 if (request.method === "POST" && url.pathname === "/ingest") {
52 return this.ingest(request);
53 }
54 return new Response("not found", { status: 404 });
55 }
56
57 private async ingest(request: Request): Promise<Response> {
58 const body = (await request.json()) as IngestRequest;
59 const { event, discordWebhookUrl } = body;
60
61 const existing =
62 (await this.state.storage.get<BatchState>(STATE_KEY)) ?? null;
63
64 const changed = collectChanged(event);
65
66 const now = Date.now();
67 const batch: BatchState = existing ?? {
68 fileKey: event.file_key,
69 fileName: event.file_name,
70 fileDescription: event.description ?? "",
71 firstSeenAt: now,
72 items: emptyItems(),
73 discordWebhookUrl,
74 flushAttempts: 0,
75 };
76
77 // If we already had a batch, refresh mutable metadata from the latest event.
78 batch.fileName = event.file_name;
79 if (event.description && event.description.trim().length > 0) {
80 batch.fileDescription = event.description;
81 }
82 // Always keep the most recent webhook URL (in case operator updated KV).
83 batch.discordWebhookUrl = discordWebhookUrl;
84
85 mergeInto(batch.items.components, changed.components);
86 mergeInto(batch.items.styles, changed.styles);
87 mergeInto(batch.items.variables, changed.variables);
88
89 await this.state.storage.put(STATE_KEY, batch);
90
91 // Schedule a flush if none is already pending. Self-heal past alarms
92 // (e.g. if a prior alarm() threw before clearing state) by treating a
93 // stale alarm as absent.
94 const currentAlarm = await this.state.storage.getAlarm();
95 if (currentAlarm === null || currentAlarm < now) {
96 await this.state.storage.setAlarm(now + BATCH_WINDOW_MS);
97 }
98
99 return new Response("ok", { status: 200 });
100 }
101
102 async alarm(): Promise<void> {
103 const batch = await this.state.storage.get<BatchState>(STATE_KEY);
104 if (!batch) return;
105
106 batch.flushAttempts += 1;
107
108 const componentsCount = Object.keys(batch.items.components).length;
109 const stylesCount = Object.keys(batch.items.styles).length;
110 const variablesCount = Object.keys(batch.items.variables).length;
111 const totalItems = componentsCount + stylesCount + variablesCount;
112
113 // Nothing changed (deletion-only publish, or cleared upstream) -> drop.
114 if (totalItems === 0) {
115 console.log(
116 `no changed items for ${batch.fileKey}; dropping (likely deletion-only publish)`,
117 );
118 await this.state.storage.delete(STATE_KEY);
119 return;
120 }
121
122 const embed = buildEmbed(batch);
123
124 let success = false;
125 let res: Response | undefined;
126 try {
127 res = await sendToDiscord(batch.discordWebhookUrl, embed);
128 success = res.ok;
129 if (!success) {
130 const body = await res.text().catch(() => "<unreadable>");
131 console.error(
132 `Discord POST failed for ${batch.fileKey}: ${res.status} ${res.statusText} body=${body.slice(0, 500)}`,
133 );
134 }
135 } catch (err) {
136 console.error(`Discord POST threw for ${batch.fileKey}:`, err);
137 }
138
139 if (success) {
140 await this.state.storage.delete(STATE_KEY);
141 return;
142 }
143
144 // Dead webhook -> retrying won't help. Drop immediately so the operator
145 // is the bottleneck (they need to update the KV mapping), not our retry.
146 if (res?.status === 401 || res?.status === 404) {
147 console.error(
148 `Discord webhook dead for ${batch.fileKey} (${res.status}); dropping without retry. Update KV with new URL.`,
149 );
150 await this.state.storage.delete(STATE_KEY);
151 return;
152 }
153
154 // Retry budget exhausted -> drop the batch to avoid a loop.
155 if (batch.flushAttempts >= MAX_FLUSH_ATTEMPTS) {
156 console.error(
157 `Dropping batch for ${batch.fileKey} after ${batch.flushAttempts} failed attempts`,
158 );
159 await this.state.storage.delete(STATE_KEY);
160 return;
161 }
162
163 // On 429, honor Retry-After (clamped to a sane [60s, 10min] range).
164 // All other failures use the fixed RETRY_DELAY_MS.
165 let retryDelayMs = RETRY_DELAY_MS;
166 if (res?.status === 429) {
167 const retryAfter = res.headers.get("retry-after");
168 const secs = retryAfter ? Number.parseFloat(retryAfter) : NaN;
169 if (Number.isFinite(secs) && secs > 0) {
170 retryDelayMs = Math.min(Math.max(secs * 1000, 60_000), 10 * 60_000);
171 }
172 }
173
174 // Schedule a single retry.
175 await this.state.storage.put(STATE_KEY, batch);
176 await this.state.storage.setAlarm(Date.now() + retryDelayMs);
177 }
178}