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.

initial commit: pigeon

Cloudflare Worker that relays Figma LIBRARY_PUBLISH events to a Discord
channel webhook. uses a Durable Object per file_key to coalesce the
separate per-asset-type events Figma emits into a single batched Discord
embed within a 60s window, with a single retry on Discord failure.

Signed-off-by: eti <eti@eti.tf>

eti e1c9dabb

+997
+3
.dev.vars.example
··· 1 + # Copy to .dev.vars for local wrangler dev runs. 2 + # Do NOT commit .dev.vars. 3 + FIGMA_PASSCODE=replace-me-with-some-actual-key # hint: `cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 16`
+12
.gitignore
··· 1 + node_modules/ 2 + .wrangler/ 3 + .dev.vars 4 + dist/ 5 + .DS_Store 6 + *.log 7 + .env 8 + .env.local 9 + 10 + # Local wrangler config with account-specific ids / custom domain. 11 + # Forkers copy wrangler.toml.example -> wrangler.toml. 12 + wrangler.toml
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 pigeon contributors 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+201
README.md
··· 1 + # pigeon 2 + 3 + a little carrier pigeon that ferries Figma `LIBRARY_PUBLISH` events to a Discord channel webhook. Cloudflare Worker. coalesces per-asset-type events into a single Discord embed inside a 60s window via a Durable Object (one DO instance per `file_key`). one retry at +5 min on Discord failure, then drop. 4 + 5 + stack: Cloudflare Workers + Durable Objects (SQLite storage + alarms) + Workers KV. no external services, no runtime deps. 6 + 7 + ## prerequisites 8 + 9 + - Cloudflare account. Workers Free is sufficient. 10 + - Figma team admin access + personal access token with `webhooks:write` scope. 11 + - Discord channel incoming webhook url. 12 + - `bun` installed (`https://bun.sh`). swap to `npm`/`pnpm` by editing `package.json` scripts if needed. 13 + 14 + ## setup 15 + 16 + all commands run from the repo root. 17 + 18 + 1. copy config template: 19 + 20 + ``` 21 + cp wrangler.toml.example wrangler.toml 22 + ``` 23 + 24 + 2. install deps: 25 + 26 + ``` 27 + bun install 28 + ``` 29 + 30 + 3. authenticate with Cloudflare (one-time): 31 + 32 + ``` 33 + bunx wrangler login 34 + ``` 35 + 36 + 4. (optional) pin deploys to a specific Cloudflare account: 37 + 38 + ``` 39 + export CLOUDFLARE_ACCOUNT_ID=<account-id> # from `bunx wrangler whoami` 40 + ``` 41 + 42 + 5. create KV namespace. paste the returned `id` into `wrangler.toml` under `[[kv_namespaces]]`: 43 + 44 + ``` 45 + bunx wrangler kv namespace create FIGMA_DISCORD_WEBHOOK 46 + ``` 47 + 48 + 6. map your Figma `file_key` to the Discord webhook url: 49 + 50 + ``` 51 + bunx wrangler kv key put --binding=FIGMA_DISCORD_WEBHOOK --remote \ 52 + "<FIGMA_FILE_KEY>" "<DISCORD_WEBHOOK_URL>" 53 + ``` 54 + 55 + 7. (optional) bind a custom domain. uncomment and edit the `[[routes]]` block in `wrangler.toml`: 56 + 57 + ``` 58 + [[routes]] 59 + pattern = "figma.example.com" 60 + custom_domain = true 61 + ``` 62 + 63 + the parent zone must already exist on this Cloudflare account. 64 + 65 + 8. deploy: 66 + 67 + ``` 68 + bun run deploy 69 + ``` 70 + 71 + note the deployed url (either `<name>.<subdomain>.workers.dev` or your custom domain). 72 + 73 + 9. set the passcode secret (random string, used for auth between Figma and Worker): 74 + 75 + ``` 76 + openssl rand -hex 32 | bunx wrangler secret put FIGMA_PASSCODE 77 + ``` 78 + 79 + save the same value; step 10 uses it. 80 + 81 + 10. register the Figma webhook: 82 + 83 + ``` 84 + curl -X POST \ 85 + -H "X-FIGMA-TOKEN: <FIGMA_PAT>" \ 86 + -H "Content-Type: application/json" \ 87 + "https://api.figma.com/v2/webhooks" \ 88 + -d '{ 89 + "event_type": "LIBRARY_PUBLISH", 90 + "team_id": "<FIGMA_TEAM_ID>", 91 + "endpoint": "<WORKER_URL>/figma", 92 + "passcode": "<SAME_PASSCODE_AS_STEP_9>", 93 + "description": "Design system publish -> Discord" 94 + }' 95 + ``` 96 + 97 + Figma sends `PING` immediately. Worker returns 200; no Discord message is posted for `PING`. 98 + 99 + 11. test: publish a library change in Figma. Discord message arrives ~60s later. 100 + 101 + ## endpoints 102 + 103 + - `POST /figma` — Figma webhook receiver. validates `passcode`. accepts `PING` and `LIBRARY_PUBLISH`. other event types return 200 and are ignored. 104 + - `GET /health` — returns `healthy` with 200. 105 + 106 + all other routes return 404. 107 + 108 + ## opt-out 109 + 110 + include `#exclude` (case-insensitive) anywhere in the Figma publish description. Worker drops the event. 111 + 112 + ## key / value reference 113 + 114 + - `FIGMA_FILE_KEY`: path segment in `figma.com/file/<KEY>/…` or `figma.com/design/<KEY>/…`. 115 + - `FIGMA_TEAM_ID`: path segment in `figma.com/files/team/<ID>/…`. 116 + - `CLOUDFLARE_ACCOUNT_ID`: from `bunx wrangler whoami`. 117 + - `DISCORD_WEBHOOK_URL`: Discord channel → edit channel → integrations → webhooks → new webhook. 118 + - `FIGMA_PAT`: Figma → settings → security → personal access tokens. scope: `webhooks:write`. 119 + 120 + ## files 121 + 122 + - `src/index.ts` — fetch handler, passcode check, route to Batcher DO. 123 + - `src/batcher.ts` — Durable Object. merges items by key, schedules 60s alarm, flushes to Discord, one retry on failure. 124 + - `src/discord.ts` — embed builder + POST. respects Discord field limits (1024 char value, 15 items per field). 125 + - `src/figma.ts` — payload types, `isExcluded()`, `figmaFileUrl()`, `collectChanged()`. 126 + - `src/types.ts` — `Env`, `BatchState`, `BatchItems`, `LibraryItem`. 127 + 128 + ## config knobs 129 + 130 + in `src/batcher.ts`: 131 + 132 + - `BATCH_WINDOW_MS = 60_000` — coalescing window from first event. 133 + - `RETRY_DELAY_MS = 300_000` — delay before single retry on Discord failure. 134 + - `MAX_FLUSH_ATTEMPTS = 2` — total attempts (initial + retry). higher values risk loops. 135 + 136 + in `src/discord.ts`: 137 + 138 + - `MAX_ITEMS_PER_FIELD = 15` 139 + - `FIELD_VALUE_MAX = 1024` 140 + 141 + ## Figma event handling 142 + 143 + input: Figma `LIBRARY_PUBLISH` payload. separate events fire per asset type (components, styles, variables) within milliseconds of each other. all three categories are merged into one batch per `file_key` via `env.BATCHER.idFromName(file_key)`. 144 + 145 + output: one Discord embed per batch window. fields: components (N), styles (N), variables (N). each field lists up to 15 item names; overflow renders as "+ X more". deleted items are not shown. 146 + 147 + ## limits 148 + 149 + - Figma: 20 webhooks per team. team-context webhooks do not fire for invite-only project files. 150 + - Discord: 1024 chars per field value, 6000 chars per embed total. enforced in `src/discord.ts`. 151 + - Workers Free: ample for typical design system publish frequency. 152 + 153 + ## debugging 154 + 155 + live logs: 156 + 157 + ``` 158 + bun run tail 159 + ``` 160 + 161 + Figma webhook delivery history (last 7 days): 162 + 163 + ``` 164 + curl -H "X-FIGMA-TOKEN: <PAT>" \ 165 + "https://api.figma.com/v2/webhooks/<WEBHOOK_ID>/requests" 166 + ``` 167 + 168 + list webhooks on a team: 169 + 170 + ``` 171 + curl -H "X-FIGMA-TOKEN: <PAT>" \ 172 + "https://api.figma.com/v2/teams/<TEAM_ID>/webhooks" 173 + ``` 174 + 175 + delete a webhook: 176 + 177 + ``` 178 + curl -X DELETE -H "X-FIGMA-TOKEN: <PAT>" \ 179 + "https://api.figma.com/v2/webhooks/<WEBHOOK_ID>" 180 + ``` 181 + 182 + typecheck source: 183 + 184 + ``` 185 + bun run typecheck 186 + ``` 187 + 188 + local dev (needs a tunnel to receive Figma webhooks): 189 + 190 + ``` 191 + cp .dev.vars.example .dev.vars # fill in FIGMA_PASSCODE 192 + bun run dev 193 + ``` 194 + 195 + ## acknowledgements 196 + 197 + inspired by [`bryanberger/figma-discord-webhook`](https://github.com/bryanberger/figma-discord-webhook). the `#exclude` opt-out convention is borrowed from that project. no code was copied. 198 + 199 + ## license 200 + 201 + MIT. see `LICENSE`.
+197
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "pigeon", 7 + "devDependencies": { 8 + "@cloudflare/workers-types": "^4.20250101.0", 9 + "typescript": "^5.5.0", 10 + "wrangler": "^4.0.0", 11 + }, 12 + }, 13 + }, 14 + "packages": { 15 + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], 16 + 17 + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], 18 + 19 + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260420.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y6HtAY+pS5INiD9HyO1JvvujZO24mD3eqRwPZlLXBkcT+wW8bTOve/8mVKErEzEtZ5LkuT3tJqG9py8TxQEBgw=="], 20 + 21 + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260420.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7aiRtZTc5S4aKcL6uIx+B3tCzb/bULjQmE67/03k0HtaDNzP20GnYmYpFCqleFqsdmIb4Tx8PkKPmsXI3AJLvQ=="], 22 + 23 + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260420.1", "", { "os": "linux", "cpu": "x64" }, "sha512-J/DW149FPmug1wSM32zBF7My14xg+inIYwzS4bSAxyXR6tBiTxbhgFWQQz99nt08ZMstdKHRD6f6C/KQaleQcA=="], 24 + 25 + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260420.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a5I147McRM/L4YHu9EwOsoAyIExZndPRQoLx/33dbw/yUEnO825gvn5QZkCGXBVL2JwsPAyowB0Xliqrj+71Sw=="], 26 + 27 + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260420.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ZrHqlHbJNU8P24EAOBaZ6B44G9P+po2z0DBwbAr8965aWR+vohy3cfmgE9uzNPAQfKNmvq7fmc4VwsRpERkg0w=="], 28 + 29 + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260420.1", "", {}, "sha512-DHT9JnSn9cIiCSdL76OxW+Xvc1+ml1CWzWvgVwreoHQ+E604aeFxPPHp9X7nE+XRWm2NH4l0OgtxUI5T/nuI3g=="], 30 + 31 + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], 32 + 33 + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], 34 + 35 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], 36 + 37 + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], 38 + 39 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], 40 + 41 + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], 42 + 43 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], 44 + 45 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], 46 + 47 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], 48 + 49 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], 50 + 51 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], 52 + 53 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], 54 + 55 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], 56 + 57 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], 58 + 59 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], 60 + 61 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], 62 + 63 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], 64 + 65 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], 66 + 67 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], 68 + 69 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], 70 + 71 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], 72 + 73 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], 74 + 75 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], 76 + 77 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], 78 + 79 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], 80 + 81 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], 82 + 83 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], 84 + 85 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], 86 + 87 + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], 88 + 89 + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], 90 + 91 + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], 92 + 93 + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], 94 + 95 + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], 96 + 97 + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], 98 + 99 + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], 100 + 101 + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], 102 + 103 + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], 104 + 105 + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], 106 + 107 + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], 108 + 109 + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], 110 + 111 + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], 112 + 113 + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], 114 + 115 + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], 116 + 117 + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], 118 + 119 + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], 120 + 121 + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], 122 + 123 + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], 124 + 125 + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], 126 + 127 + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], 128 + 129 + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], 130 + 131 + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], 132 + 133 + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], 134 + 135 + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], 136 + 137 + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 138 + 139 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 140 + 141 + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], 142 + 143 + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], 144 + 145 + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], 146 + 147 + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], 148 + 149 + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], 150 + 151 + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], 152 + 153 + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], 154 + 155 + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 156 + 157 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 158 + 159 + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], 160 + 161 + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], 162 + 163 + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 164 + 165 + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], 166 + 167 + "miniflare": ["miniflare@4.20260420.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260420.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-w8s3eh2W7EEsFh2uGdddZLkbTwiPI8MCSMXKtuLSA9btW8xmQsVVSkrFuLXFyTKcX0QkstS5dhcWjQPQRJ2WKg=="], 168 + 169 + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], 170 + 171 + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 172 + 173 + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], 174 + 175 + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], 176 + 177 + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], 178 + 179 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 180 + 181 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 182 + 183 + "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], 184 + 185 + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], 186 + 187 + "workerd": ["workerd@1.20260420.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260420.1", "@cloudflare/workerd-darwin-arm64": "1.20260420.1", "@cloudflare/workerd-linux-64": "1.20260420.1", "@cloudflare/workerd-linux-arm64": "1.20260420.1", "@cloudflare/workerd-windows-64": "1.20260420.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-1AOJgng169u4fiFrEd5WjrAGpdwd3A4ZJtP8PMvf+RF9NUKy+mdwrKdz4qPZ6Tt/Bya99vsLn6UX33fjAEVoaA=="], 188 + 189 + "wrangler": ["wrangler@4.84.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260420.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260420.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260420.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-lYScYXeHZ385rDzbTF7QfP4FWu2vQuD7uDQRUjDZuutyq5fZVCR6ZxLLsySbqFiFjvKsF5RoxVPeJtI78blz4w=="], 190 + 191 + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], 192 + 193 + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], 194 + 195 + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], 196 + } 197 + }
+19
package.json
··· 1 + { 2 + "name": "pigeon", 3 + "version": "0.1.0", 4 + "description": "pigeon carries Figma LIBRARY_PUBLISH events to a Discord channel webhook. Cloudflare Worker with 60s batching.", 5 + "private": true, 6 + "type": "module", 7 + "license": "MIT", 8 + "scripts": { 9 + "dev": "wrangler dev", 10 + "deploy": "wrangler deploy", 11 + "tail": "wrangler tail", 12 + "typecheck": "tsc --noEmit" 13 + }, 14 + "devDependencies": { 15 + "@cloudflare/workers-types": "^4.20250101.0", 16 + "typescript": "^5.5.0", 17 + "wrangler": "^4.0.0" 18 + } 19 + }
+150
src/batcher.ts
··· 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 + 16 + import type { BatchItems, BatchState, Env, LibraryItem } from "./types.js"; 17 + import { buildEmbed, sendToDiscord } from "./discord.js"; 18 + import type { LibraryPublishEvent } from "./figma.js"; 19 + import { collectChanged } from "./figma.js"; 20 + 21 + const BATCH_WINDOW_MS = 60_000; 22 + const RETRY_DELAY_MS = 5 * 60_000; 23 + const MAX_FLUSH_ATTEMPTS = 2; // initial + one retry 24 + 25 + const STATE_KEY = "batch"; 26 + 27 + interface IngestRequest { 28 + event: LibraryPublishEvent; 29 + discordWebhookUrl: string; 30 + } 31 + 32 + function emptyItems(): BatchItems { 33 + return { components: {}, styles: {}, variables: {} }; 34 + } 35 + 36 + function 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 + 46 + export 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. 92 + const currentAlarm = await this.state.storage.getAlarm(); 93 + if (currentAlarm === null) { 94 + await this.state.storage.setAlarm(now + BATCH_WINDOW_MS); 95 + } 96 + 97 + return new Response("ok", { status: 200 }); 98 + } 99 + 100 + async alarm(): Promise<void> { 101 + const batch = await this.state.storage.get<BatchState>(STATE_KEY); 102 + if (!batch) return; 103 + 104 + batch.flushAttempts += 1; 105 + 106 + const componentsCount = Object.keys(batch.items.components).length; 107 + const stylesCount = Object.keys(batch.items.styles).length; 108 + const variablesCount = Object.keys(batch.items.variables).length; 109 + const totalItems = componentsCount + stylesCount + variablesCount; 110 + 111 + // Nothing changed (deletion-only publish, or cleared upstream) -> drop. 112 + if (totalItems === 0) { 113 + await this.state.storage.delete(STATE_KEY); 114 + return; 115 + } 116 + 117 + const embed = buildEmbed(batch); 118 + 119 + let success = false; 120 + try { 121 + const res = await sendToDiscord(batch.discordWebhookUrl, embed); 122 + success = res.ok; 123 + if (!success) { 124 + console.error( 125 + `Discord POST failed for ${batch.fileKey}: ${res.status} ${res.statusText}`, 126 + ); 127 + } 128 + } catch (err) { 129 + console.error(`Discord POST threw for ${batch.fileKey}:`, err); 130 + } 131 + 132 + if (success) { 133 + await this.state.storage.delete(STATE_KEY); 134 + return; 135 + } 136 + 137 + // Retry budget exhausted -> drop the batch to avoid a loop. 138 + if (batch.flushAttempts >= MAX_FLUSH_ATTEMPTS) { 139 + console.error( 140 + `Dropping batch for ${batch.fileKey} after ${batch.flushAttempts} failed attempts`, 141 + ); 142 + await this.state.storage.delete(STATE_KEY); 143 + return; 144 + } 145 + 146 + // Schedule a single retry. 147 + await this.state.storage.put(STATE_KEY, batch); 148 + await this.state.storage.setAlarm(Date.now() + RETRY_DELAY_MS); 149 + } 150 + }
+102
src/discord.ts
··· 1 + /** 2 + * Build and send Discord channel webhook messages. 3 + * 4 + * Discord embed limits we respect: 5 + * - field.value <= 1024 chars 6 + * - up to 25 fields 7 + * - total embed <= 6000 chars 8 + * See: https://discord.com/developers/docs/resources/webhook 9 + */ 10 + 11 + import type { BatchState, LibraryItem } from "./types.js"; 12 + import { figmaFileUrl } from "./figma.js"; 13 + 14 + const MAX_ITEMS_PER_FIELD = 15; 15 + const FIELD_VALUE_MAX = 1024; 16 + 17 + export interface DiscordEmbed { 18 + title: string; 19 + url: string; 20 + description?: string; 21 + timestamp?: string; 22 + color?: number; 23 + fields?: Array<{ name: string; value: string; inline?: boolean }>; 24 + footer?: { text: string }; 25 + } 26 + 27 + /** 28 + * Format a list of library items into a Discord field value. Truncates to 29 + * MAX_ITEMS_PER_FIELD entries and/or the field-value character limit, adding 30 + * a "+ X more" tail when necessary. 31 + */ 32 + function formatItemList(items: LibraryItem[]): string { 33 + if (items.length === 0) return ""; 34 + 35 + const shown = items.slice(0, MAX_ITEMS_PER_FIELD); 36 + const remaining = items.length - shown.length; 37 + 38 + const lines = shown.map((it) => `\u2022 ${it.name}`); 39 + if (remaining > 0) lines.push(`\u2026 + ${remaining} more`); 40 + 41 + let value = lines.join("\n"); 42 + if (value.length > FIELD_VALUE_MAX) { 43 + // Hard cap: trim and append an ellipsis. 44 + value = value.slice(0, FIELD_VALUE_MAX - 1) + "\u2026"; 45 + } 46 + return value; 47 + } 48 + 49 + export function buildEmbed(batch: BatchState): DiscordEmbed { 50 + const components = Object.values(batch.items.components); 51 + const styles = Object.values(batch.items.styles); 52 + const variables = Object.values(batch.items.variables); 53 + 54 + const fields: DiscordEmbed["fields"] = []; 55 + if (components.length > 0) { 56 + fields.push({ 57 + name: `Components (${components.length})`, 58 + value: formatItemList(components), 59 + }); 60 + } 61 + if (styles.length > 0) { 62 + fields.push({ 63 + name: `Styles (${styles.length})`, 64 + value: formatItemList(styles), 65 + }); 66 + } 67 + if (variables.length > 0) { 68 + fields.push({ 69 + name: `Variables (${variables.length})`, 70 + value: formatItemList(variables), 71 + }); 72 + } 73 + 74 + const embed: DiscordEmbed = { 75 + title: `\u{1F4E6} ${batch.fileName} \u2014 library published`, 76 + url: figmaFileUrl(batch.fileKey, batch.fileName), 77 + timestamp: new Date(batch.firstSeenAt).toISOString(), 78 + color: 0x0acf83, // Figma green-ish 79 + }; 80 + 81 + if (batch.fileDescription && batch.fileDescription.trim().length > 0) { 82 + embed.description = batch.fileDescription.slice(0, 4096); 83 + } 84 + if (fields.length > 0) embed.fields = fields; 85 + 86 + return embed; 87 + } 88 + 89 + /** 90 + * POST an embed to a Discord channel webhook URL. Returns the HTTP response 91 + * so the caller can decide whether to retry. 92 + */ 93 + export async function sendToDiscord( 94 + webhookUrl: string, 95 + embed: DiscordEmbed, 96 + ): Promise<Response> { 97 + return fetch(webhookUrl, { 98 + method: "POST", 99 + headers: { "Content-Type": "application/json" }, 100 + body: JSON.stringify({ embeds: [embed] }), 101 + }); 102 + }
+85
src/figma.ts
··· 1 + /** 2 + * Types and helpers for Figma Webhooks V2 payloads. 3 + * 4 + * Reference: 5 + * https://developers.figma.com/docs/rest-api/webhooks/ 6 + * https://developers.figma.com/docs/rest-api/webhooks-types/ 7 + */ 8 + 9 + import type { LibraryItem } from "./types.js"; 10 + 11 + /** Every webhook payload includes a passcode and event_type discriminator. */ 12 + interface BaseEvent { 13 + event_type: string; 14 + passcode: string; 15 + timestamp: string; 16 + webhook_id: string; 17 + } 18 + 19 + export interface PingEvent extends BaseEvent { 20 + event_type: "PING"; 21 + } 22 + 23 + /** 24 + * LIBRARY_PUBLISH fires when components, styles, or variables are published 25 + * from a library file. Separate events may fire per asset type, which is why 26 + * we batch within a short window before notifying Discord. 27 + */ 28 + export interface LibraryPublishEvent extends BaseEvent { 29 + event_type: "LIBRARY_PUBLISH"; 30 + file_key: string; 31 + file_name: string; 32 + /** Optional publish description written by the designer. */ 33 + description?: string; 34 + created_components: LibraryItem[]; 35 + modified_components: LibraryItem[]; 36 + deleted_components: LibraryItem[]; 37 + created_styles: LibraryItem[]; 38 + modified_styles: LibraryItem[]; 39 + deleted_styles: LibraryItem[]; 40 + /** Variables are newer; present on Enterprise libraries. */ 41 + created_variables?: LibraryItem[]; 42 + modified_variables?: LibraryItem[]; 43 + deleted_variables?: LibraryItem[]; 44 + triggered_by?: { 45 + id: string; 46 + handle: string; 47 + }; 48 + } 49 + 50 + export type FigmaWebhookEvent = 51 + | PingEvent 52 + | LibraryPublishEvent 53 + | (BaseEvent & { event_type: string }); // forward-compat fallthrough 54 + 55 + /** Returns true if the publish description opts out of notifications. */ 56 + export function isExcluded(description: string | undefined): boolean { 57 + if (!description) return false; 58 + return /#exclude\b/i.test(description); 59 + } 60 + 61 + /** Flattens create/modify arrays (we treat both as "changed"). Deletions ignored. */ 62 + export function collectChanged( 63 + event: LibraryPublishEvent, 64 + ): { components: LibraryItem[]; styles: LibraryItem[]; variables: LibraryItem[] } { 65 + return { 66 + components: [ 67 + ...(event.created_components ?? []), 68 + ...(event.modified_components ?? []), 69 + ], 70 + styles: [ 71 + ...(event.created_styles ?? []), 72 + ...(event.modified_styles ?? []), 73 + ], 74 + variables: [ 75 + ...(event.created_variables ?? []), 76 + ...(event.modified_variables ?? []), 77 + ], 78 + }; 79 + } 80 + 81 + /** Build the public figma.com URL for a file. */ 82 + export function figmaFileUrl(fileKey: string, fileName: string): string { 83 + const slug = encodeURIComponent(fileName.replace(/\s+/g, "-")); 84 + return `https://www.figma.com/file/${fileKey}/${slug}`; 85 + }
+111
src/index.ts
··· 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 + 11 + import type { Env } from "./types.js"; 12 + import type { FigmaWebhookEvent, LibraryPublishEvent } from "./figma.js"; 13 + import { isExcluded } from "./figma.js"; 14 + 15 + export { Batcher } from "./batcher.js"; 16 + 17 + function ok(body = "ok"): Response { 18 + return new Response(body, { status: 200 }); 19 + } 20 + 21 + async 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 + // Figma sends PING immediately after webhook creation. 42 + if (payload.event_type === "PING") { 43 + console.log("received PING"); 44 + return ok(); 45 + } 46 + 47 + if (payload.event_type !== "LIBRARY_PUBLISH") { 48 + // Forward-compat: accept unknown event types silently. 49 + console.log(`ignoring event_type=${payload.event_type}`); 50 + return ok(); 51 + } 52 + 53 + const event = payload as LibraryPublishEvent; 54 + 55 + if (isExcluded(event.description)) { 56 + console.log(`skipping ${event.file_key}: #exclude in description`); 57 + return ok(); 58 + } 59 + 60 + const discordWebhookUrl = await env.FIGMA_DISCORD_WEBHOOK.get(event.file_key); 61 + if (!discordWebhookUrl) { 62 + console.log(`no KV mapping for file_key=${event.file_key}; skipping`); 63 + return ok(); 64 + } 65 + 66 + // Route to the per-file Durable Object; fire-and-forget so Figma gets 200 fast. 67 + const id = env.BATCHER.idFromName(event.file_key); 68 + const stub = env.BATCHER.get(id); 69 + const ingestReq = new Request("https://batcher/ingest", { 70 + method: "POST", 71 + headers: { "Content-Type": "application/json" }, 72 + body: JSON.stringify({ event, discordWebhookUrl }), 73 + }); 74 + 75 + ctx.waitUntil( 76 + (async () => { 77 + try { 78 + const res = await stub.fetch(ingestReq); 79 + if (!res.ok) { 80 + console.error( 81 + `Batcher ingest failed for ${event.file_key}: ${res.status}`, 82 + ); 83 + } 84 + } catch (err) { 85 + console.error(`Batcher ingest threw for ${event.file_key}:`, err); 86 + } 87 + })(), 88 + ); 89 + 90 + return ok(); 91 + } 92 + 93 + export default { 94 + async fetch( 95 + request: Request, 96 + env: Env, 97 + ctx: ExecutionContext, 98 + ): Promise<Response> { 99 + const url = new URL(request.url); 100 + 101 + if (request.method === "GET" && url.pathname === "/health") { 102 + return ok("healthy"); 103 + } 104 + 105 + if (request.method === "POST" && url.pathname === "/figma") { 106 + return handleFigma(request, env, ctx); 107 + } 108 + 109 + return new Response("not found", { status: 404 }); 110 + }, 111 + } satisfies ExportedHandler<Env>;
+40
src/types.ts
··· 1 + /** 2 + * Shared types for the worker. 3 + */ 4 + 5 + export interface Env { 6 + /** KV: key = Figma file_key, value = Discord channel webhook URL. */ 7 + FIGMA_DISCORD_WEBHOOK: KVNamespace; 8 + /** Durable Object namespace for the Batcher class. */ 9 + BATCHER: DurableObjectNamespace; 10 + /** Shared secret that Figma echoes back in every webhook payload. */ 11 + FIGMA_PASSCODE: string; 12 + } 13 + 14 + /** A single published item from a Figma library (component, style, or variable). */ 15 + export interface LibraryItem { 16 + key: string; 17 + name: string; 18 + } 19 + 20 + /** Buckets of items pending flush for one file. */ 21 + export interface BatchItems { 22 + components: Record<string, LibraryItem>; 23 + styles: Record<string, LibraryItem>; 24 + variables: Record<string, LibraryItem>; 25 + } 26 + 27 + /** Persistent state held by a Batcher Durable Object for a single file. */ 28 + export interface BatchState { 29 + fileKey: string; 30 + fileName: string; 31 + /** Optional publish description from the designer. */ 32 + fileDescription: string; 33 + /** Timestamp of the first event in the current batch (ms since epoch). */ 34 + firstSeenAt: number; 35 + items: BatchItems; 36 + /** The Discord webhook URL to flush to, captured from KV on first ingest. */ 37 + discordWebhookUrl: string; 38 + /** Number of flush attempts made so far (bounded to prevent loops). */ 39 + flushAttempts: number; 40 + }
+18
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "Bundler", 6 + "lib": ["ES2022"], 7 + "types": ["@cloudflare/workers-types"], 8 + "strict": true, 9 + "noUncheckedIndexedAccess": true, 10 + "noImplicitOverride": true, 11 + "esModuleInterop": true, 12 + "skipLibCheck": true, 13 + "resolveJsonModule": true, 14 + "isolatedModules": true, 15 + "noEmit": true 16 + }, 17 + "include": ["src/**/*.ts"] 18 + }
+38
wrangler.toml.example
··· 1 + name = "pigeon" 2 + main = "src/index.ts" 3 + compatibility_date = "2025-09-01" 4 + 5 + # Pin deploys to a specific Cloudflare account by one of: 6 + # 1. export CLOUDFLARE_ACCOUNT_ID=<your-account-id> (recommended; keeps repo reusable) 7 + # 2. uncomment the line below and paste your id 8 + # Get your account id via: bunx wrangler whoami 9 + # account_id = "" 10 + 11 + [observability] 12 + enabled = true 13 + 14 + # KV namespace mapping: Figma file_key -> Discord channel webhook URL. 15 + # Create with: 16 + # bunx wrangler kv namespace create FIGMA_DISCORD_WEBHOOK 17 + # then paste the returned id below. 18 + [[kv_namespaces]] 19 + binding = "FIGMA_DISCORD_WEBHOOK" 20 + id = "REPLACE_WITH_KV_NAMESPACE_ID" 21 + 22 + # Durable Object: one actor per Figma file_key. Holds the current batch of 23 + # publish events and fires an alarm ~60s after the first event to flush a 24 + # single aggregated message to Discord. 25 + [[durable_objects.bindings]] 26 + name = "BATCHER" 27 + class_name = "Batcher" 28 + 29 + [[migrations]] 30 + tag = "v1" 31 + new_sqlite_classes = ["Batcher"] 32 + 33 + # Optional: bind a custom domain instead of the default <name>.<subdomain>.workers.dev. 34 + # Requires the parent zone to already exist on this Cloudflare account. 35 + # Uncomment and set to your chosen hostname. 36 + # [[routes]] 37 + # pattern = "figma.example.com" 38 + # custom_domain = true