---
title: "Architecture"
description: "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 in `src/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 `event` table 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 in `src/shared/event-value.ts`) is extracted at ingest into its own columns: `value_amount`/`value_currency` in D1 (migration `0002` backfilled older rows), `double3`/`blob13` in 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=d1` keeps 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.ts` hands the Web `Request` to `PublicHttp` (`src/server/public-api/router.ts`) with `HttpEffect.toWebHandlerWith`, using the isolate's app services. `PublicHttp` assigns the request id, rejects bodies over 1 MiB, then routes with Effect's `HttpRouter`. MCP tool calls run the same endpoints in-process on a request built with `HttpServerRequest.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.created` on the queue when `notify` is 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 calls `POST /api/auth/dev/sign-in` (`src/server/platform/dev-sign-in.ts`, a small Better Auth plugin). It creates a session for `dev@example.com`, creating that user on first use. The endpoint is always registered, so `auth.api` types stay stable, but it answers 404 unless `import.meta.env.DEV` is true and `APP_URL` is a `localhost` host. Every build compiles `import.meta.env.DEV` to `false`. 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.ts` checks both halves. Better Auth's `testUtils` plugin wasn't used because its docs say to keep it out of the real auth config.

## Effect

- Every service is a `Context.Service` with a `static layer`. Failures are tagged errors (`src/server/errors.ts`) that adapters map to HTTP/oRPC errors.
- `src/worker/runtime.ts` builds the application Layer once per isolate. If that build stalls or fails, the next request rebuilds it.
- Background work uses structured concurrency (`Effect.forEach` with 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.

- `HttpApi` was 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_id` has no length limit; the router's default of 100 characters would reject valid ids.
- `/api/v1/*` aliases are registered as separate routes rather than through `HttpRouter.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 `unstable` namespace, so its API can change between Effect 4 releases. The `02-public-api` scenario 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](https://useblume.dev) 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 build` runs `vite build`, the marketing prerender, `blume build`, then `scripts/bundle-docs.ts`, which copies `docs/dist` to `dist/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/x` to `/www/docs/x/index.html` like any other site page. Unknown docs pages get the docs 404, and `.md`/`.txt` files are served with `charset=utf-8`.
- Redirects in `blume.config.ts` ship as meta-refresh pages. Blume's host files (`_headers`, `_redirects`, `vercel.json`) aren't copied.
- The marketing `robots.txt` lists the docs sitemap, because crawlers only read `/robots.txt` at 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 in `blume.config.ts`.
- `pnpm docs:dev` runs Blume's dev server for writing; `pnpm dev` doesn't serve `/docs`.
- `pnpm typecheck` runs `blume validate --strict`, which checks links and frontmatter. It doesn't run `blume 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 |
