feat: add webhooks via Cloudflare Queues and Cron Triggers#142
feat: add webhooks via Cloudflare Queues and Cron Triggers#142ephraimduncan wants to merge 4 commits into
Conversation
Deliver submission.created webhooks using Cloudflare Queues (delivery, retry/backoff, dead-letter queue) and Cron Triggers (safety-net sweep and log cleanup) — no Redis, BullMQ, or separately deployed worker, replacing the approach abandoned in #121. - schema: webhook_delivery_logs table + forms.enable_webhook / webhook_url / webhook_secret (migration 0003) - producer: submission route and form.testWebhook enqueue via WEBHOOK_QUEUE, deferred with after() (never a floating promise) - consumer/cron: custom OpenNext worker entry (worker.ts) exporting queue() and scheduled(); process.env hydrated before dynamic imports so the workerd handlers can reach the validated env - HMAC-SHA256 request signing via WebCrypto with X-Formbase-Signature, X-Formbase-Event and X-Formbase-Timestamp headers and a per-form secret - UI: webhook settings, read-only signing secret, and recent deliveries list - SSRF-hardened URL validation; signing secret is never exposed through the public getFormById procedure - cron sweep atomically leases stuck rows to avoid duplicate delivery
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
formbase-web | edc6a96 | Jun 02 2026, 11:43 PM |
|
sweet |
Address low-severity review findings: - consumer: wrap DLQ markFailed in try/finally so the message is always acked even if the status write throws (prevents a stranded DLQ batch) - isValidWebhookUrl: close SSRF denylist gaps — full 127.0.0.0/8, 0.0.0.0, bracket-stripped IPv6 loopback/ULA/link-local, and integer/hex IP encodings - form settings: gate the Test button on the live URL field + dirty state (and reset after save) so it can't test a stale, unsaved URL
Next 16 builds with Turbopack, which refuses to follow symlinks that resolve outside the project root. Two things broke the build in this bun monorepo: apps/web resolves dependencies from the hoisted root node_modules (a level above apps/web), and bun's isolated linker also symlinked some deps (@base-ui/react, @tailus/themer, tailwind plugins) into its global cache outside the repo entirely. - next.config.js: set outputFileTracingRoot + turbopack.root to the monorepo root so Turbopack can resolve the root node_modules - bunfig.toml: use the hoisted install linker so every dependency lives inside the repo (no global-cache symlinks for Turbopack to reject)
- Use space-y-2 for the webhook signing-secret block to match sibling sections - Drop stray pnpm from db:migrate so it runs tsx directly like the other db scripts
aikins01
left a comment
There was a problem hiding this comment.
thanks for pushing this forward. i found a few blockers that need fixing before this can merge: CI is failing, the public form lookup exposes webhook targets, and the stuck-delivery sweep can overrun queue batch limits.
| .notNull(), | ||
| defaultSubmissionEmail: text('default_submission_email'), | ||
| honeypotField: text('honeypot_field').default('_gotcha').notNull(), | ||
| enableWebhook: integer('enable_webhook', { |
There was a problem hiding this comment.
this new column isn't mirrored in the handwritten Vitest schema in tests/helpers/db.ts, so existing tests still create forms without enable_webhook / webhook_url / webhook_secret. The current unit job crashes with SQLITE_ERROR: no such column: enable_webhook; can we update the test schema and reset order for webhook_delivery_logs too?
| defaultSubmissionEmail: true, | ||
| honeypotField: true, | ||
| enableWebhook: true, | ||
| webhookUrl: true, |
There was a problem hiding this comment.
this makes the configured webhook endpoint visible through the public form lookup. Anyone with a form id can now fetch the target URL even though only the server needs it for delivery; can we keep getFormById to submitter-safe fields and fetch webhook config in a private/server-side path instead?
| leaseMs: 900000, | ||
| }); | ||
| if (stuck.length) { | ||
| await queue.sendBatch( |
There was a problem hiding this comment.
this leases every stuck row before sending one queue batch. Cloudflare Queues caps sendBatch at 100 messages and 256 KB total, so a busy backlog can throw after the rows are leased and leave them quiet until the lease expires; can we page or chunk the sweep before updating, or send in bounded batches?
|
|
||
| if (!spamResult.isSpam && form.enableWebhook && form.webhookUrl) { | ||
| const { env } = getCloudflareContext(); | ||
| const queue = env.WEBHOOK_QUEUE; |
There was a problem hiding this comment.
this untyped getCloudflareContext().env.WEBHOOK_QUEUE path is causing the lint job to fail here and in the tRPC context helpers. Can we type the Cloudflare env binding instead of suppressing it so CI is green and queue access stays checked?
Summary
Adds form webhooks — when a submission arrives, formbase POSTs it to a user-configured URL with retries — built entirely on Cloudflare-native primitives. This replaces the approach in #121 (BullMQ + Redis + a separately deployed Fly.io worker), which required an always-on external service.
How it works
The custom OpenNext worker entry (
apps/web/worker.ts) re-exports the generatedfetchhandler + cache DOs and addsqueue()/scheduled(). It hydratesprocess.envfrom the bindings before dynamically importing the consumer/scheduled modules, so the workerd handlers can reach the validated env (whichnext dev's request context normally provides).What's better than #121
timestamp.body, sent asX-Formbase-Signature/X-Formbase-Event/X-Formbase-Timestamp, with a per-formwebhook_secret.after()(the pattern that caused the submission hang fixed in fix: prevent submission endpoint hang on Cloudflare Workers #141), notvoid promise.webhook_delivery_logstable in feat: add webhook queue system and worker #121 was never surfaced).idis a stable idempotency key for receivers.getFormByIdprocedure (explicit column allowlist).UPDATE … SET next_retry_at … RETURNING) so it can't double-deliver rows still in the queue's own retry window.Changes
webhook_delivery_logstable;forms.enable_webhook/webhook_url/webhook_secret; migration0003_awesome_inhumans.producer,deliver(HMAC),consumer(backoff + DLQ),scheduled(sweep + cleanup).form.updatewebhook fields + server-generated secret,form.testWebhook,form.listDeliveries;setFormDatanow returns{ id }; queue threaded through context.wrangler.jsoncqueues/DLQ/cron +main→./worker.ts;cloudflare-env.d.tsgitignored;tsconfigcheckJs:false.apps/worker/andpackages/queue/leftovers from feat: add webhook queue system and worker #121.Verification
@formbase/db,@formbase/utils,@formbase/api: typecheck clean.Before deploying
wrangler queues create formbase-webhooksandwrangler queues create formbase-webhooks-dlq.packages/db→db:migrate).wrangler dev/opennextjs-cloudflare preview(notnext dev); validate end-to-end against awebhook.siteURL.Known follow-ups (low severity)
markFailedcould be wrapped intry/finallybeforeack().isValidWebhookUrldenylist has IPv6/numeric-host gaps (backstopped byglobal_fetch_strictly_public).