Skip to content
EmitKitDocs
Esc
↑↓navigate↵open⌘Jpreview
On this page

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. The marketing site is emitkit.com’s; a deployment without EMITKIT_SITE_HOST (the self-hosting default) serves only the app and API hosts.

                 ┌──────────────────────── 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, callbacks     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. Optional: without SITE_URL, every host other than the API’s is the app, and docs links point at emitkit.com (AppConfig.docsUrl)

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, Autumn (billing API), Context.dev (brand API), Telemetry (trakoo), SecretBox (sealed API keys), Svix webhook signatures, 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, billing, organizations (memberships, brand), retention, emoji, public-api, mcp, cards (fields, actions and answers), telemetry (event registry and milestones)
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, better-auth-ui), marketing site (prerendered by scripts/prerender-www.ts), shared design tokens and the small components HeroUI v3 lacks (web/ui: list view, segment, the push notification card the site and the paywall share). No licensed UI kit: HeroUI Pro was replaced so the repository builds from a plain clone. recharts draws the KPI sparklines of the feed’s pulse (web/app/features/events/kpi.tsx). The homepage hero runs the same feed and pulse (@app/features/events/event-feed and pulse-view) on made-up data (web/www/landing/dashboard-demo*.tsx). Those two components take their data as props and import neither the router nor the RPC client. The demo is a lazily loaded chunk that renders only after hydration, so recharts loads after the page is up and never enters the prerendered HTML. @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, sealed API keys, the billing cache, organization brands, per-organization event counts for telemetry milestones, event cards).
  • Sealed API keys (api_key_secret, migration 0004): Better Auth keeps only a hash of each key. ApiKeys.create also stores the key sealed with AES-256-GCM, under a key derived by HKDF from BETTER_AUTH_SECRET, with the key id as additional data (SecretBox). Owners and admins can reveal a key again over RPC (not MCP). The row goes with the key (foreign-key cascade on revoke and rotate). Keys issued before 0004 can’t be revealed until rotated.
  • 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. Each endpoint is a PublicApi operation (src/server/public-api/public-api.ts): it declares the scope it needs, runs for a Principal and a decoded input, and has a table of the statuses and bodies of its errors. authenticate resolves the principal once from the credential: organization, project, scopes (src/shared/scopes.ts, kept in the API key’s metadata; a key without scopes has all of them), rate limit. One encoder authenticates, checks the scope (403 forbidden), decodes, runs and writes the envelope and rate-limit headers for REST routes and MCP tool calls alike. MCP authenticates once per HTTP request, before any method, so a bad key is an HTTP 401 and tools/list only shows the tools the principal’s scopes allow. Reads (GET /v1/events, /v1/channels, /v1/me) take their input from the query string; GET /v1/events runs the dashboard feed’s query (Events.page) in the key’s project. Its cursor is the last event’s time and id (src/shared/cursor.ts), so events sharing a millisecond are neither skipped nor repeated; the store orders by time, then id, and leaves out deleted channels itself, so a page is never short while more events exist.
  • 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 → the card when the event has fields or actions (event_card) → write to event store → event.created on the queue when notify is true or the event waits for an answer. createEvent, getEvent and cancelEvent all return the same event shape.
  • Questions (events with buttons or fields, docs/content/concepts/questions.mdx): ingest also writes the event’s card to event_card (migration 0007), before the event itself, so an event that waits never exists without it. The card is a self-contained snapshot (title, fields, actions, answer), so the answer page never depends on the event store (D1 keeps events only 25 hours next to Analytics Engine). The dashboard feed joins each page’s cards (links, and “Waiting for an answer” with an Answer button). The push opens /a/{id} in the PWA, which reads and answers the card over RPC (answers.get, answers.submit) for any organization the member belongs to. First answer wins: one update … where status = 'pending' and expires_at_ms > now. Expiry is decided when a card is read; only a question with a callback also schedules an expiry check (below). An answer is also written as an event in the same channel. Cards go with the retention sweep after 90 days, like the events. Questions in a deleted project or channel can’t be read or answered. Objects inside fields and actions reject unknown settings (STRICT_PARSE), while the event itself stays lenient; answers are decoded with a schema built from the question’s fields (answerSchema). The field and action schemas live in src/shared/actions.ts, shared by the API, MCP (whose tool schema is generated from them) and the answer page.
  • Callbacks (callbackUrl on an event that waits): answering queues answer.callback; a question with a callback also queues answer.expire, delayed until its expiry (Cloudflare Queues delay messages by at most 24 hours, so a longer expiry re-queues itself). The expiry check marks the card expired (answering and canceling only change a pending card whose time isn’t up, so a question gets event.answered or event.expired, never both). It is also the backstop for a callback whose queue send failed: an answered or expired card with no attempt yet gets its callback then. Callbacks (src/server/cards/callbacks.ts) sends each attempt through CallbackSender (src/server/platform/callbacks.ts): a POST signed like Standard Webhooks, a 10 s timeout, redirects not followed, the response body not read. A failed attempt queues the next one as a new message (about 16 hours of backoff). If EmitKit itself fails mid-attempt (D1 or the queue), the consumer retries the message: the POST may repeat with the same webhook-id, attempts are recorded by number so they count once, and a delivered callback is never sent or marked again. Delivery is at least once. Each project’s secret is derived with HKDF from BETTER_AUTH_SECRET (nothing stored), shown to owners and admins over RPC; rotating BETTER_AUTH_SECRET rotates every callback secret, and there’s no way yet to rotate one project’s. The Worker runs with global_fetch_strictly_public: callback URLs are chosen by senders, and without the flag a request to another host on the Worker’s zone goes straight to that host’s origin, past the zone’s security settings. Outgoing webhooks were dropped from v3 on purpose; these are back because a serverless sender can’t wait for an answer, and they only ever go to the URL the sender gave for that one event.
  • Rate limit: Better Auth’s api-key plugin counts requests per key. It is patched (patches/@better-auth__api-key@1.7.5.patch) so a window is fixed: lastRequest stays at the window’s start instead of moving with every accepted request. Unpatched, the count only reset after 60 idle seconds, so any client sending more often than once a minute (a poller) was blocked after 100 requests. X-RateLimit-Reset is the window’s start plus 60 s, and the dashboard’s “Last used” is at most a minute behind.
  • After the write: once the event is stored, marking the project’s first event and enqueueing the push are retried briefly and then logged, never failed. A 500 there would make a retry with the same Idempotency-Key store the event twice.
  • 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.
  • Passwordless sign-in: an emailed six-digit code (Better Auth emailOTP), not a magic link. On iPhone a link from Mail opens in Safari, which keeps its own cookies, so the Home Screen app would stay signed out; a code is typed into the app itself. Codes are stored encrypted, expire after 5 minutes, allow 3 tries, and asking again resends the same code (resendStrategy: "reuse") so a reload while the person is in Mail doesn’t void the code they are holding.
  • Signed-in visitors: sign-in, sign-up, email-otp and forgot-password send someone who already has a session to their redirectTo (same-origin paths only, never back into /auth) or to / (leaveGuestAuthPage in web/app/lib/session.ts). Re-authentication (reauthenticate=true) still shows the form, and a failed session check shows it too.
  • Signed-in hint: the session cookie is HttpOnly and host-only on the app host, so the site can’t see it. Auth.handle (src/server/platform/session-hint.ts) adds an emitkit_signed_in=1 cookie on the site’s domain whenever Better Auth sets the session cookie, with the same lifetime, and clears it whenever the session cookie is cleared. get-session adds a missing hint and clears one whose session is gone. The hint holds no secret and isn’t HttpOnly: an inline script in the site’s <head> reads it and marks <html data-signed-in>, and an inline style shows Dashboard instead of Sign in and Get started from the first paint. Both header variants are in the prerendered markup, so there is no hydration mismatch and nothing moves. The hint only exists when the app host is a subdomain of the site host (app.emitkit.com under emitkit.com), so never without a site. Under pnpm dev Chrome rejects Domain=localhost, so the local site always shows Sign in.
  • First run: / gives a signed-in user without an organization a workspace (gettingStarted.ensureWorkspace: an organization named after them, a project and a starter key; idempotent) and opens it with ?guide=setup. The setup guide (a modal in the $org layout) reveals the starter key, fills it into the curl and SDK snippets, waits for the first event (or sends one with gettingStarted.sendTestEvent, a real notify event through ingest and the queue) and fires confetti when it lands, then opens the phone setup (?guide=phone). The phone setup, also in the account menu, shows a QR code that opens the app on a phone, the iPhone and Android install steps, and turns on push for the current device. /getting-started redirects to /.
  • Push preferences: push is set up per device, across every organization the user belongs to (PushPreferencesFlow). Settings → Notifications turns the device on once (subscribing it in every organization, all channels), then switches organizations off or narrows them to channels. Delivery still uses one push_subscription row per organization, user and device. When a user creates or joins an organization, their devices are subscribed there too (Notifications.extendToOrganization, from Better Auth’s organization hooks).
  • Push health: people rely on these alerts, so push that stops on a device must not stop silently. Turning push on remembers the device’s endpoint in localStorage, per account (emitkit:push-endpoint:<userId>); turning it off, or switching every organization off, forgets it. Each time the app opens or comes back to the foreground (at most every 5 minutes), useDevicePushHealth reads the browser’s subscription and calls notifications.syncDevice. If the browser replaced its subscription, the rows under the remembered endpoint move to the new one with their organization and channel choices, and the move restarts the FRESH_MS window. Changed keys are saved without touching updated_at, so a dead subscription still ages out and is deleted. If the browser dropped its subscription while permission stands, the app subscribes again without a prompt. If push was on and no organization gets it any more (the push service reported it gone, or re-subscribing failed), a notice says so, and Turn back on subscribes afresh instead of reusing the dead registration. Blocked permission gets its own notice. The service worker handles pushsubscriptionchange the same way where browsers fire it (Safari doesn’t); the app-open check covers the rest. Signing out first calls notifications.disableEverywhere for this device (time-limited, never blocking sign-out) and clears the markers, so the next person on the device doesn’t get the previous account’s pushes. The browser keeps its subscription for whoever turns push on next; after signing back in, push is turned on again in Settings → Notifications.
  • Hosted billing (only when AUTUMN_SECRET_KEY is set; self-hosted deployments skip all of it): the Autumn customer is the organization. Billing keeps Autumn’s view of each organization’s plan in organization_billing (migration 0003) and re-reads it when older than 15 minutes, after checkout, and on Autumn’s billing.updated webhook (POST /api/billing/webhook on the app host, Svix-signed). A new organization needs a plan from the start (required): sign-up lands on the paywall modal, which starts a 14-day, card-required trial through Stripe’s checkout and returns into the setup guide (?guide=setup&checkout=done; the dashboard asks Autumn directly until the plan shows up). A plan that ends makes it ended. In both states the queue consumer skips event pushes, and the dashboard shows the paywall modal over its pages (it steps aside while Settings is open). Events are still accepted and recorded, so the public API contract doesn’t change. If Autumn is unreachable, the cache decides and pushes keep going. Seats are members plus pending invitations: Better Auth’s beforeCreateInvitation hook refuses an invitation past the plan’s seats, and Settings → Billing adds a seat after showing Autumn’s price preview. Deleting an organization cancels its subscription. Billing webhooks push notices to the organization’s own devices (“Payment failed”, “Your plan ended”, “Trial ended”) through Notifications.announce, whatever the billing state. The Autumn customer is created with the organization (Billing.registerCustomer, from Better Auth’s afterCreateOrganization), so it can be found in Autumn’s admin from the start. Attaching the hidden comp plan there grants free access (unlimited seats), shown as comped; extending a trial or adding a coupon in Autumn works the same way, through the webhook. The plans are defined in autumn.config.ts and mirrored for display in src/shared/billing.ts. Organizations that existed before billing launched are comped until 2027-03-25.
  • Operator telemetry (only when its keys are set; self-hosted deployments send nothing): Telemetry (src/server/platform/telemetry.ts) reports how the deployment is used to whoever runs it, through trakoo. The events are one typed registry, src/server/telemetry/events.ts. With TELEMETRY_EMITKIT_API_KEY, the events whose category has a push channel go to that EmitKit project with notify on: signups (new sign-ups), billing (trials, subscriptions, conversions, lifetime purchases, plan changes, seats added or removed, failed payments, cancellations, ended plans), milestones (an organization’s first event, then 100, 1,000, … events) and churn (deleted accounts and organizations, including those deleted with their owner’s account). EmitKit is notifying its operator about EmitKit: the Worker calls api.emitkit.com, which is itself, and a Custom Domain accepts that. Events that charge someone carry amount and currency, so the operator’s EmitKit dashboard shows the revenue. With TELEMETRY_OPENPANEL_CLIENT_ID and …_SECRET, OpenPanel gets every event, plus the product-usage ones (organizations created, invitations, test events, push turned on, checkouts Autumn opened) and user profiles. Sign-ups send the visitor’s IP and user agent so OpenPanel can place them (one that isn’t a valid HTTP header value is left out, and the event still goes); EmitKit keeps the user agent in the event’s metadata, never the IP. Events come from where they become true: Better Auth’s database and organization hooks (sign-up, deletion, invitations), Billing (checkout, seats), the billing webhook (billing-telemetry.ts reads Autumn’s plan_changes; reported last, so a webhook that fails and is retried reports once), PublicApi (milestones) and the setup flows. Delivery runs after the response through waitUntil and never fails the request; an EmitKit call gives up after 5 seconds and isn’t retried. With no keys the service is a no-op and never loads the SDKs. Local dev sign-in and the E2E accounts (e2e/support/targets.ts) are never reported.
  • Page views (only with the OpenPanel keys): the Worker adds the browser-public client id to every page it serves (emitkit:openpanel-client-id, next to the other runtime meta tags in src/worker/assets.ts; the secret never leaves the Worker). web/ui/visits.ts reports page views to OpenPanel through trakoo’s browser provider. The PWA reports each route it resolves. The site and the docs (Blume’s own build) get a small page-view script, /www/ek.js (built from web/ui/visits-page.ts under a fixed name so the Worker can add it), which also follows the docs’ client-side navigation. The PWA names the signed-in user with their Better Auth id, the profile id the server reports under, whenever the session query settles (web/app/lib/visitor.ts), so page views join the same profile as sign-ups, trials and payments. Sign-out and account deletion forget them. URLs keep only utm_* and ref in their query string, so reset tokens and invitation ids never leave the browser. Automated browsers (navigator.webdriver, as in E2E runs) and the test accounts report nothing. The SDK loads only when the meta tag is there, so self-hosted pages load a 2 KB module and send nothing.
  • Milestones count events accepted through the public API and MCP in organization_usage (migration 0006), one upsert per event after the response, only while telemetry is on. The dashboard’s test event isn’t counted. The migration seeds organizations that already had events, so they aren’t reported as sending their first one; with Analytics Engine as the event store their count starts from the D1 hot buffer (the last ~25 hours, at least 1), so their next milestones undercount. An organization that sends events while telemetry is off is counted from zero once it’s turned on.
  • Brand and recipes on the paywall (only with billing on; the lookup also needs CONTEXT_DEV_API_KEY): the first time an owner or admin sees the paywall of the workspace EmitKit made for them (still named “Ada’s workspace”), workspace.brand looks up the domain of their email address with Context.dev (OrganizationBrands, src/server/organizations/brand.ts). Only the domain is sent, and mailbox providers (gmail.com and the others in src/shared/domains.ts) are never looked up. A brand that’s found is kept in organization_brand (migration 0007): its square icon becomes the organization’s logo unless one was set, and its name replaces the default “Ada’s workspace” (the slug stays). Its industry (Context.dev’s EIC labels) picks a segment, and the paywall shows that segment’s recipes (src/shared/recipes.ts): four example pushes, milestones only, and what that business would leave out, since EmitKit isn’t a log drain. The owner can correct it with their website (workspace.lookupBrand, five paid lookups per organization) or pick a kind of business by hand (not stored). Organizations they create later aren’t looked up by email domain; they get the website field. Without a brand the paywall shows the SaaS set and asks. “Nothing found” is cached; a failed call isn’t, so the next paywall view tries again. Members only read what’s stored.
  • 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.
  • Each route is registered once. src/worker/router.ts rewrites the /api/v1/* and /api/openapi.json aliases to /v1/* and /openapi.json before the request reaches the router. HttpRouter.prefixed wasn’t used for them: 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 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>) Sign-in codes, 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, event cards

Why these dependencies

Dependencies added after the initial build, and the reason each one exists:

Dependency Needed for
Context.dev (hosted service, no SDK) The brand behind a new organization’s domain: name, square icon and industry, for its avatar and the paywall’s recipes. Called with fetch from src/server/platform/context-dev.ts (POST /v1/brand/retrieve, by_domain, 10 credits a lookup), so there’s no runtime package. Only the domain is sent. Only used when CONTEXT_DEV_API_KEY is set; the icons it returns are served from media.brand.dev.
Autumn (hosted service, no SDK) Hosted plans, trials, seats, Stripe checkout and the billing portal. Called with fetch from src/server/platform/autumn.ts, so there’s no runtime package. Only used when AUTUMN_SECRET_KEY is set.
atmn (dev) Autumn’s CLI: autumn.config.ts keeps the plans in the repository, and pnpm billing:push previews and applies them
@number-flow/react The prices on the site’s pricing section roll between Monthly, Annual and Lifetime (respects reduced motion; renders plain text when prerendered)
trakoo Operator telemetry: one typed event registry, delivered to EmitKit and OpenPanel with per-destination routing. Only loads a provider when its keys are set.
@trakoo/emitkit, @trakoo/openpanel trakoo’s EmitKit and OpenPanel providers. Since trakoo 2 they are separate packages, so the core ships no SDK-backed providers
@emitkit/js, @openpanel/sdk The SDKs those providers load (lazily, as separate chunks) when telemetry is on. @trakoo/emitkit needs @emitkit/js 3, which calls fetch unbound (2.1.0 called it as a method of its config object, which workerd rejects with “Illegal invocation”)
@openpanel/web The browser SDK @trakoo/openpanel’s client provider loads (lazily) for page views, only when the OpenPanel keys are set. Pinned to the release that uses the same @openpanel/sdk as the server
uqr The QR code in the phone setup, which opens the app on a phone (a small, dependency-free encoder; drawn as an SVG image)
standardwebhooks (dev) The E2E callback tests verify EmitKit’s signatures with the library receivers use, so a signing mistake can’t pass by matching our own code. Not in the Worker.

Was this page helpful?