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 129 lines 3.6 kB view raw
1/** 2 * HTTP entrypoint for the worker. 3 * 4 * Exposes: 5 * POST /figma - Figma webhook endpoint 6 * GET /health - liveness probe 7 * 8 * All other routes return 404. 9 */ 10 11import type { Env } from "./types.js"; 12import type { FigmaWebhookEvent, LibraryPublishEvent } from "./figma.js"; 13import { isExcluded } from "./figma.js"; 14 15export { Batcher } from "./batcher.js"; 16 17function ok(body = "ok"): Response { 18 return new Response(body, { status: 200 }); 19} 20 21async function handleFigma( 22 request: Request, 23 env: Env, 24 ctx: ExecutionContext, 25): Promise<Response> { 26 let payload: FigmaWebhookEvent; 27 try { 28 payload = (await request.json()) as FigmaWebhookEvent; 29 } catch { 30 return new Response("invalid json", { status: 400 }); 31 } 32 33 // Constant-time-ish passcode check. Figma echoes the value we registered. 34 if ( 35 typeof payload.passcode !== "string" || 36 payload.passcode !== env.FIGMA_PASSCODE 37 ) { 38 return new Response("unauthorized", { status: 401 }); 39 } 40 41 // Record liveness so /health can surface upstream silences. Reserved 42 // __-prefixed key; operators must not use __-prefixed Figma file_keys. 43 // Fire-and-forget so Figma keeps getting a fast 200. 44 ctx.waitUntil( 45 env.FIGMA_DISCORD_WEBHOOK.put( 46 "__last_figma_request_at", 47 new Date().toISOString(), 48 ), 49 ); 50 51 // Figma sends PING immediately after webhook creation. 52 if (payload.event_type === "PING") { 53 console.log("received PING"); 54 return ok(); 55 } 56 57 if (payload.event_type !== "LIBRARY_PUBLISH") { 58 // Forward-compat: accept unknown event types silently. 59 console.log(`ignoring event_type=${payload.event_type}`); 60 return ok(); 61 } 62 63 const event = payload as LibraryPublishEvent; 64 65 if (isExcluded(event.description)) { 66 console.log(`skipping ${event.file_key}: #exclude in description`); 67 return ok(); 68 } 69 70 const discordWebhookUrl = await env.FIGMA_DISCORD_WEBHOOK.get(event.file_key); 71 if (!discordWebhookUrl) { 72 console.warn( 73 `no KV mapping for file_key=${event.file_key} (${event.file_name}); triggered_by=${event.triggered_by?.handle ?? "unknown"}; skipping`, 74 ); 75 return ok(); 76 } 77 78 // Route to the per-file Durable Object; fire-and-forget so Figma gets 200 fast. 79 const id = env.BATCHER.idFromName(event.file_key); 80 const stub = env.BATCHER.get(id); 81 const ingestReq = new Request("https://batcher/ingest", { 82 method: "POST", 83 headers: { "Content-Type": "application/json" }, 84 body: JSON.stringify({ event, discordWebhookUrl }), 85 }); 86 87 ctx.waitUntil( 88 (async () => { 89 try { 90 const res = await stub.fetch(ingestReq); 91 if (!res.ok) { 92 console.error( 93 `Batcher ingest failed for ${event.file_key}: ${res.status}`, 94 ); 95 } 96 } catch (err) { 97 console.error(`Batcher ingest threw for ${event.file_key}:`, err); 98 } 99 })(), 100 ); 101 102 return ok(); 103} 104 105export default { 106 async fetch( 107 request: Request, 108 env: Env, 109 ctx: ExecutionContext, 110 ): Promise<Response> { 111 const url = new URL(request.url); 112 113 if (request.method === "GET" && url.pathname === "/health") { 114 const lastFigmaRequestAt = await env.FIGMA_DISCORD_WEBHOOK.get( 115 "__last_figma_request_at", 116 ); 117 return new Response( 118 JSON.stringify({ status: "healthy", lastFigmaRequestAt }), 119 { status: 200, headers: { "Content-Type": "application/json" } }, 120 ); 121 } 122 123 if (request.method === "POST" && url.pathname === "/figma") { 124 return handleFigma(request, env, ctx); 125 } 126 127 return new Response("not found", { status: 404 }); 128 }, 129} satisfies ExportedHandler<Env>;