feat: add webhooks via Cloudflare Queues and Cron Triggers#142
feat: add webhooks via Cloudflare Queues and Cron Triggers#142ephraimduncan wants to merge 6 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 | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
formbase-web | 5222cad | Commit Preview URL Branch Preview URL |
Jun 06 2026, 08:30 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.
aikins01
left a comment
There was a problem hiding this comment.
thanks for the follow-up fixes. a few webhook delivery/security blockers still need fixes before merge.
| const isIpv6Literal = hostname.startsWith('[') && hostname.endsWith(']'); | ||
| const host = isIpv6Literal ? hostname.slice(1, -1) : hostname; | ||
|
|
||
| if (isIpv6Literal) { |
There was a problem hiding this comment.
This only blocks a few textual IPv6 forms, so production still accepts IPv4-mapped and unspecified literals such as https://[::ffff:127.0.0.1]/, https://[::ffff:10.0.0.1]/, and https://[::]/. Those endpoints then get fetched by the worker, which bypasses the private/loopback URL policy. Can we normalize/classify IP literals and reject all non-public IPv4, IPv6, and IPv4-mapped ranges?
| } catch (error) { | ||
| console.error('Failed to mark webhook delivery as failed', error); | ||
| } finally { | ||
| msg.ack(); |
There was a problem hiding this comment.
This acknowledges DLQ messages even when markFailed throws. If the DB update fails, the queue message is gone but the delivery can stay pending with attempts >= 5, so the sweeper will never pick it up again. Can we only ack() after the failed/terminal state is persisted, and retry or rethrow on DB errors?
| } else { | ||
| const delay = | ||
| BACKOFF_S[Math.min(attempt - 1, BACKOFF_S.length - 1)] ?? 60; | ||
| await markPending( |
There was a problem hiding this comment.
Cloudflare Queues are at-least-once, and the sweeper can also enqueue another message for the same deliveryLogId. If two messages process concurrently, one can mark the row success while the other later calls markPending or markFailed, downgrading a delivered webhook back to pending/failed. Can we make failure transitions conditional on the row still being non-terminal, or refetch/guard after delivery before mutating?
| }, WEBHOOK_TIMEOUT_MS); | ||
|
|
||
| try { | ||
| const response = await fetch(webhookUrl, { |
There was a problem hiding this comment.
Fetch follows redirects by default, so an allowed https:// webhook can 307/308 redirect this POST, headers, and signature to an http:// URL. That bypasses the HTTPS-only guarantee from the URL validator. Can we use redirect: 'manual' or revalidate every redirect target before following?
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).