a little carrier pigeon that ferries figma events to discord
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 178 lines 6.0 kB view raw
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}