a little carrier pigeon that ferries figma events to discord
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>;