English · 中文
A lightweight, fully serverless customer support desk on Cloudflare: real-time web widget chat with email ticketing as fallback. Zero servers to run, no per-seat licensing — one deployment serves one team.
chatpony is a support desk that runs on a single Cloudflare Worker: visitors chat in real time through an embeddable web widget, offline messages fall back to email tickets, and agents handle conversations from a same-origin dashboard.
- Full stack in one Worker: Hono API + Static Assets (dashboard / widget) + Durable Objects + Email Routing handler — a single deployment surface.
- Durable Objects as the authoritative source of truth: one ConversationDO per conversation (SQLite event log, strictly increasing seq, ACK only after commit); D1 holds cross-conversation query projections; InboxDO owns presence and the agents' live conversation list.
- R2 stores attachments and raw email MIME, Queues make email and notifications async, KV holds eventually-consistent config.
- Progressive activation: the web widget works the moment you deploy; email channel and outbound provider are enabled step by step via the dashboard wizard, degrading gracefully when unconfigured.
- No per-seat licensing: no seat-based authorization; capacity is bounded only by your Cloudflare plan quota.
Deployment requires three high-entropy secrets (AUTH_SECRET / BOOTSTRAP_TOKEN / HMAC_MASTER_SECRET); all other resources are provisioned automatically with zero IDs.
Visitor site ──(facade loader <4KB)──> iframe widget ──WS──┐
▼
┌──────── Cloudflare Worker (single surface) ────────┐
Agent dashboard ─WS/REST─> Hono API + Static Assets + email() handler │
│ │ │ │
│ ConversationDO InboxDO │
│ (authoritative (presence / │
│ event log: live conversation list) │
│ SQLite+seq+outbox) │
└──────────┼────────────────┼──────────────────────┘
▼ ▼
D1 (query projection + control plane) R2 (attachments/MIME)
Queues (MAIL/NOTIFY) KV (config)
- Write path: persist first, then ACK/broadcast — a message is committed to ConversationDO SQLite (event + outbox) before the client is ACKed and the message is projected to D1; projection is idempotent and rebuildable.
- Email: inbound Email Routing →
email()→ raw MIME in R2 → Queue → DO; outbound via Resend (optional, degrades when unconfigured). - Observability: structured Workers Logs (who / what / result),
wrangler tail(pnpm cf:tail), andGET /api/health(bindings / secrets / bootstrap readiness as booleans — values are never echoed).
pnpm install
# Local secrets: copy the template and fill in per the comments
# (generate the three required values with: openssl rand -base64 32)
cp .dev.vars.example .dev.vars
# Local dev (wrangler dev + local D1/DO/KV/R2)
pnpm build # first emit dashboard/widget static assets into apps/api/public
pnpm cf:dev
# Test
pnpm test # full suite (unit + workers dual project)
pnpm test:ci # build → test, includes the widget bundle-size budget gate
# Deploy (set the production secrets first — see the table below)
wrangler secret put AUTH_SECRET
wrangler secret put BOOTSTRAP_TOKEN
wrangler secret put HMAC_MASTER_SECRET
pnpm cf:deploy # = build + D1 migrations apply --remote + wrangler deployAll secrets must be high-entropy random values: openssl rand -base64 32. The app rejects defaults and empty values.
| Stage | What it needs | What you get |
|---|---|---|
| Deployed | The three secrets only | A workers.dev domain + working web widget (loader URL is derived from the Worker origin, never hardcoded) |
| Inbound email | Your own domain + Email Routing (DNS can't be auto-provisioned) | Tickets created/replied via email; the dashboard settings wizard guides setup |
| Outbound email | RESEND_API_KEY (optional) |
Visitor-side offline-to-email; when unconfigured a NoopProvider degrades it to a dashboard notice + logs |
After deploying, open https://<worker-domain>/app: the dashboard starts locked. The wizard asks for the one-time BOOTSTRAP_TOKEN you set at deploy time — the server verifies it in constant time and consumes it atomically via a conditional D1 write, creating the first admin, the site name, and the default web inbox. Once consumed, the bootstrap endpoint closes permanently (410), and only one of any concurrent requests succeeds. Check GET /api/health to troubleshoot.
Two-part snippet (the dashboard settings page generates a copy-paste-ready version):
<script>
window.$chatpony = { baseUrl: 'https://<worker-domain>', widgetKey: '<inbox widgetKey>' }
</script>
<script src="https://<worker-domain>/widget/loader.js" async></script>The facade loader is < 4KB gzip (pure CSS bubble, zero framework); the iframe chat bundle (< 60KB gzip) is lazy-loaded only on click or optional idle prewarm. Optional config: locale / color / position / idleMs / user (identifier + identifierHash HMAC identity verification). The host site must call window.$chatpony.reset() on logout.
| Name | Type | Required | Behavior when unset |
|---|---|---|---|
AUTH_SECRET |
secret | Yes | Login sessions unavailable (better-auth signing key) |
BOOTSTRAP_TOKEN |
secret | Yes (one-time) | Setup wizard can't create the admin; invalidated once consumed |
HMAC_MASTER_SECRET |
secret | Yes | setUser identity verification unavailable (HKDF-derived per-inbox token) |
EMAIL_DOMAIN |
var | No | Email threading/outbound degrades (send/receive domain, reply+{uuid}@) |
NOTIFY_FROM_EMAIL |
var | No | No default outbound From; also the loop-prevention drop baseline |
TEAM_NOTIFY_EMAIL |
var | No | No team offline digest email, dashboard live notices only |
RESEND_API_KEY |
secret | No | Outbound email degrades to NoopProvider, logs only |
EMAIL_WEBHOOK_SECRET |
secret | No | Skips Resend webhook signature verification, logs only |
For local dev, write .dev.vars at the repo root (gitignored; template at .dev.vars.example); in production use wrangler secret put; non-secret vars go through wrangler.jsonc / the dashboard. The Deploy to Cloudflare button reads .dev.vars.example to prompt for and set the required secrets.
- Dual project:
unit(node,*.pure.test.tspure functions — state machines / protocol / email parsing, millisecond feedback) +workers(*.worker.test.ts, real workerd + local D1/KV/DO — mocks can't exercise DO concurrency behavior). - Invariant tests backstop concurrency correctness: strictly increasing seq, replayable after ACK, idempotent and rebuildable projection, duplicate delivery yields exactly one event, read state isolated between agents.
- Bundle-size budget gate: CI tests the build artifacts directly (loader < 4KB / frame < 60KB gzip);
pnpm test:cienforces the build → test order and hard-fails when artifacts are missing in CI.
chatpony/
wrangler.jsonc # Worker config at repo root (Deploy to Cloudflare only reads root config + package.json)
apps/
api/ # Hono Worker: REST + WS + DO + email() + Static Assets
public/ # dashboard/widget build artifacts land here, deployed with the Worker (gitignored)
src/do/ # ConversationDO / InboxDO
dashboard/ # agent back office, React SPA (Vite), build -> apps/api/public/app
widget/ # visitor widget (facade loader + iframe frame), build -> apps/api/public/widget
packages/
db/ # Drizzle schema + migrations (single source of truth)
shared/ # shared types, zod schema, message protocol, state machines
- Single tenant = single deployment: the Cloudflare deployment is the tenant boundary; there is no in-app multi-tenancy or billing.
- Only two entry points: web widget + email; no omni-channel (WhatsApp/FB), SLA, complex reporting, or native app.
- Mainland-China latency, stated honestly: Cloudflare's standard network has no mainland-China PoPs, so access routes through Hong Kong / Japan / Singapore and similar edges. This project's value is low startup cost + on-demand scaling + seamless fit with the CF stack + self-hosted control — not faster mainland access; the mitigation is an extremely lightweight, lazy-loaded widget.
- Plans: demo/hobby can run within the Free tier (over-quota fails outright rather than auto-scaling); production is recommended on Workers Paid.
- Capacity: one InboxDO per inbox centralizes presence/broadcast; past a threshold this needs sharding (V2).
- V2 items: knowledge base, AI bot/suggestions, Web Push, auto-assignment, business hours/OOO, auto-resolve, CSAT, outbound webhooks, audit log, i18n.