Architecture
How one Cloudflare Worker serves the API, dashboard, site and docs, and why each resource exists.
EmitKit is one Cloudflare Worker (emitkit). It serves three hostnames, built from one Vite build and provisioned by one Alchemy stack.
┌──────────────────────── Worker "emitkit" ─────────────────────────┐
api.emitkit.com │ src/worker/router.ts ── public API (/v1/*, /api/v1/*), MCP (/mcp),│
app.emitkit.com │ (host → surface) ── Better Auth (/api/auth/*), oRPC (/rpc/*), │
emitkit.com │ ── static PWA (dist/client/app/**) │
│ ── static marketing (dist/client/www/**) │
│ queue() ── push fan-out scheduled() ── retention sweep │
└─────┬──────────┬───────────────┬──────────────┬──────────────┬────┘
D1 Analytics Engine Queue Email Sending Workers AI
Surfaces
| Host (config) | Serves |
|---|---|
API_URL host |
Public REST API from openapi/openapi.json, /openapi.json, /mcp |
APP_URL host |
The PWA, /api/auth/* (Better Auth), /rpc/* (dashboard oRPC), /api/v1/* alias |
SITE_URL host |
Prerendered marketing pages, and the docs at /docs; www. redirects to it |
src/worker/router.ts compares the request host with those three variables. Hashed build output under /assets/* bypasses the Worker (run_worker_first: ["/*", "!/assets/*"]). Other paths are mapped to the surface’s folder: /x → /app/x or /www/x, or …/index.html when the path has no extension. Unknown PWA routes fall back to the app shell, and unknown site routes get the prerendered 404 page.
Modules
| Path | Role |
|---|---|
src/worker/* |
Thin adapters: host routing, Web Request → Effect HTTP handler, oRPC router, queue/cron handlers, per-isolate ManagedRuntime |
src/server/platform/* |
Cloudflare bindings, Better Auth, Web Push, Analytics Engine, Email, AI, config — the only code allowed to touch globals (fetch, crypto, JSON) |
src/server/<domain>/* |
Effect services with typed errors: projects, channels, api-keys, events, identities, idempotency, notifications, getting-started, retention, emoji, public-api, mcp |
src/server/app-layer.ts |
Composes every Layer; the backend is one Layer built once per isolate |
src/shared/* |
Browser-safe contracts: the dashboard oRPC contract and DTO schemas, queue messages |
web/app · web/www · web/ui |
PWA (TanStack Router, HeroUI v3 + HeroUI Pro, better-auth-ui), marketing site (prerendered by scripts/prerender-www.ts), shared design tokens. recharts is a dependency only because HeroUI Pro’s KPI sparklines (the feed’s pulse) render with it. Import Pro components by subpath (@heroui-pro/react/kpi): the package root also pulls in peers this app doesn’t install, such as motion. @parsew/sdk builds brand-icon URLs (logo.parsew.com) and validates domains; with EMITKIT_PARSEW_KEY set, the browser sends Parsew the domains of project websites, of users’ email addresses (mailbox providers such as gmail.com excluded), and of links, emails and bare domains in event metadata and descriptions |
docs |
Customer docs and changelog: a separate Blume (Astro) build in its own pnpm workspace package, bundled into the Worker’s static assets at /docs |
Boundaries are enforced by oxlint.config.ts (no-restricted-imports): frontends never import backend code; src/server never imports adapters or UI; nothing uses deep relative imports.
Data
- D1 (
migrations/): Better Auth tables (users, sessions, accounts, organizations, members, invitations, API keys, auth rate limits) and EmitKit tables (projects, channels, identities, aliases, idempotency receipts, push subscriptions, event tombstones, the event hot buffer). - Workers Analytics Engine (
emitkit_events): the event store. One data point per event; the layout is documented insrc/server/events/event-store.ts. Retention is about 90 days and applied by the platform. Reads use the SQL API with a read-only account token that Alchemy mints (CF_ANALYTICS_TOKEN). - Hot buffer: Analytics Engine makes a write queryable after roughly a minute, so every event is also written to the D1
eventtable for 25 hours. Feed reads merge both stores, deduplicated by id. The hourly cron trims it. - Event value: money at the top level of an event’s metadata (
{ "amount": 49, "currency": "EUR" }, rule insrc/shared/event-value.ts) is extracted at ingest into its own columns:value_amount/value_currencyin D1 (migration0002backfilled older rows),double3/blob13in Analytics Engine (points written before that read as no value). The dashboard’s Revenue sums it without parsing JSON. - Pulse (
events.pulse): event counts and value sums in equal buckets (1 h × 24, 6 h × 28 or 1 day × 30), for the range and the one before it. Buckets that start within the last 24 hours come from the hot buffer (exact); older ones from Analytics Engine (SUM(_sample_interval), so sample-weighted estimates). Revenue uses the currency most valued events carry; other currencies are not added in. - Events cannot be updated in Analytics Engine: deletions and identity erasure are applied at read time (
event_tombstone,erased_identity). EVENT_STORE=d1keeps all events in D1 for 90 days instead. It is used for local development and suits self-hosters who don’t need Analytics Engine.
Request flows
- Public API and MCP:
src/worker/public-api.tshands the WebRequesttoPublicHttp(src/server/public-api/router.ts) withHttpEffect.toWebHandlerWith, using the isolate’s app services.PublicHttpassigns the request id, rejects bodies over 1 MiB, then routes with Effect’sHttpRouter. MCP tool calls run the same endpoints in-process on a request built withHttpServerRequest.fromClientRequest. - Ingest
POST /v1/events: API key verified by Better Auth’s api-key plugin (hash lookup + 100 req/min limit) → idempotency claim (optional) → schema validation → channel get-or-create → identity alias resolution → write to event store →event.createdon the queue whennotifyis true. - Push: the queue consumer loads the organization’s push subscriptions that match the channel, sends RFC 8291 Web Push with VAPID (
@pushforge/builder), and deletes subscriptions the push service reports gone. - Dashboard: the PWA calls
/rpc/*with its session cookie. Every procedure resolves the session’s active organization and checks membership. Nothing trusts an organization id sent by the client. - Local dev sign-in: under
pnpm dev, “Continue in local dev” on the sign-in page callsPOST /api/auth/dev/sign-in(src/server/platform/dev-sign-in.ts, a small Better Auth plugin). It creates a session fordev@example.com, creating that user on first use. The endpoint is always registered, soauth.apitypes stay stable, but it answers 404 unlessimport.meta.env.DEVis true andAPP_URLis alocalhosthost. Every build compilesimport.meta.env.DEVtofalse. The Worker bundle still registers the endpoint, but it always answers 404, and the client bundle drops the button entirely.08-dev-sign-in.spec.tschecks both halves. Better Auth’stestUtilsplugin wasn’t used because its docs say to keep it out of the real auth config.
Effect
- Every service is a
Context.Servicewith astatic layer. Failures are tagged errors (src/server/errors.ts) that adapters map to HTTP/oRPC errors. src/worker/runtime.tsbuilds the application Layer once per isolate. If that build stalls or fails, the next request rebuilds it.- Background work uses structured concurrency (
Effect.forEachwith bounded concurrency for queue batches and push fan-out).
oRPC evaluation
The dashboard contract (src/shared/rpc/contract.ts) uses oRPC with @orpc/experimental-effect: Effect Schema contracts, .effect() generator handlers, and services provided through effect/context. The public API does not use oRPC’s OpenAPI handler. Reproducing openapi.json exactly would mean overriding the status codes, headers (rate limit, idempotency replay, WWW-Authenticate) and error bodies of every operation. Instead the checked-in spec is served verbatim.
Effect HTTP for the public API
The public API and MCP run on effect/unstable/http: HttpRouter for routing, HttpServerRequest/HttpServerResponse in the endpoints (src/server/public-api, src/server/mcp), and HttpEffect.toWebHandlerWith in the Worker adapter.
HttpApiwas not used, for the same reason as oRPC’s OpenAPI handler: it derives its own OpenAPI document and error encoding, while this contract is a fixed file with hand-specified bodies and headers.- The router is configured to match paths exactly as published: case-sensitive, no trailing- or duplicate-slash folding. Path parameters may be up to 8 KB long, because
user_idhas no length limit; the router’s default of 100 characters would reject valid ids. /api/v1/*aliases are registered as separate routes rather than throughHttpRouter.prefixed. A prefixed route gets a copied request, and the 1 MiB check has already read (and cached) the body on the original one.- Cost: about 200 kB more Worker bundle (about 50 kB gzipped). The module lives in Effect’s
unstablenamespace, so its API can change between Effect 4 releases. The02-public-apiscenario pins the wire behaviour (the routing contract test) to catch that.
Linting
Ultracite (Oxlint + oxfmt) with the React and TanStack presets. oxlint-plugin-effect’s recommended preset applies to src/server/**, with two deliberate relaxations:
effect/noNullish: D1 rows, Better Auth and JSON APIs are nullable.effect/noTernary: a style preference.
Platform adapters (src/server/platform/**) may use globals, async functions and Promises, as the plugin’s README recommends. Better Auth’s option file and its plugins (auth-options.ts, dev-sign-in.ts) may use as const and throw APIError, because Better Auth infers its API types from literal tuples and rejects requests by throwing. Four core rules that misread Effect idioms are off for src/**; each is commented in oxlint.config.ts.
Docs site
docs is the documentation: the customer guides, this self-hosting section, the changelog, and an API reference generated from openapi/openapi.json. Blume builds it into static files with Astro. It is a separate build, rather than part of the Vite build, because Blume owns its own Astro pipeline. It lives in its own workspace package so its dependency tree (Astro, sharp, pagefind) stays out of the Worker’s.
pnpm buildrunsvite build, the marketing prerender,blume build, thenscripts/bundle-docs.ts, which copiesdocs/disttodist/client/www/docs. Nothing new is deployed: the same Worker serves the files.- Blume builds with
deployment.base: "/docs", so every link, script and stylesheet already carries the prefix. The Worker maps/docs/xto/www/docs/x/index.htmllike any other site page. Unknown docs pages get the docs 404, and.md/.txtfiles are served withcharset=utf-8. - Redirects in
blume.config.tsship as meta-refresh pages. Blume’s host files (_headers,_redirects,vercel.json) aren’t copied. - The marketing
robots.txtlists the docs sitemap, because crawlers only read/robots.txtat the root. docs.<domain>(EMITKIT_DOCS_HOST), where the previous Mintlify docs lived, is attached to the Worker and 301s to<site>/docs/<same path>. Mintlify-era paths that moved (/api-reference/*,/self-hosting/*,/concepts/webhooks) have redirects inblume.config.ts.pnpm docs:devruns Blume’s dev server for writing;pnpm devdoesn’t serve/docs.pnpm typecheckrunsblume validate --strict, which checks links and frontmatter. It doesn’t runblume check, because Blume 1.7.3’s generated navigation code fails its own type check.
Why these resources
| Resource | Needed for |
|---|---|
| D1 | All relational state, auth, hot buffer |
| Analytics Engine | 90-day event store and stats |
Queue emitkit-events |
Reliable push fan-out with retries, off the ingest path |
Email Sending (mail.<domain>) |
Magic links, password reset, invitations |
| Workers AI | Channel emoji suggestions (keyword fallback when absent) |
alchemy-state-store Worker + Secrets Store |
Alchemy’s deploy state (resource ids, generated secrets), so any machine or Workers Builds can deploy; not part of the product |
| Cron (hourly) | Retention: hot buffer, tombstones, idempotency receipts, stale identities, purged projects, orphan keys |