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, migration0004): Better Auth keeps only a hash of each key.ApiKeys.createalso stores the key sealed with AES-256-GCM, under a key derived by HKDF fromBETTER_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 before0004can’t be revealed until rotated. - 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. Each endpoint is aPublicApioperation (src/server/public-api/public-api.ts): it declares the scope it needs, runs for aPrincipaland a decoded input, and has a table of the statuses and bodies of its errors.authenticateresolves 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 (403forbidden), 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 andtools/listonly 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/eventsruns 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.createdon the queue whennotifyis true or the event waits for an answer.createEvent,getEventandcancelEventall return the same event shape. - Questions (events with buttons or fields,
docs/content/concepts/questions.mdx): ingest also writes the event’s card toevent_card(migration0007), 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: oneupdate … 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 insidefieldsandactionsreject 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 insrc/shared/actions.ts, shared by the API, MCP (whose tool schema is generated from them) and the answer page. - Callbacks (
callbackUrlon an event that waits): answering queuesanswer.callback; a question with a callback also queuesanswer.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 cardexpired(answering and canceling only change a pending card whose time isn’t up, so a question getsevent.answeredorevent.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 throughCallbackSender(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 samewebhook-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 fromBETTER_AUTH_SECRET(nothing stored), shown to owners and admins over RPC; rotatingBETTER_AUTH_SECRETrotates every callback secret, and there’s no way yet to rotate one project’s. The Worker runs withglobal_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:lastRequeststays 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-Resetis 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-Keystore 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/(leaveGuestAuthPageinweb/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 anemitkit_signed_in=1cookie 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-sessionadds 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.comunderemitkit.com), so never without a site. Underpnpm devChrome rejectsDomain=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$orglayout) reveals the starter key, fills it into the curl and SDK snippets, waits for the first event (or sends one withgettingStarted.sendTestEvent, a realnotifyevent 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-startedredirects 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 onepush_subscriptionrow 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),useDevicePushHealthreads the browser’s subscription and callsnotifications.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 theFRESH_MSwindow. Changed keys are saved without touchingupdated_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 handlespushsubscriptionchangethe same way where browsers fire it (Safari doesn’t); the app-open check covers the rest. Signing out first callsnotifications.disableEverywherefor 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_KEYis set; self-hosted deployments skip all of it): the Autumn customer is the organization.Billingkeeps Autumn’s view of each organization’s plan inorganization_billing(migration0003) and re-reads it when older than 15 minutes, after checkout, and on Autumn’sbilling.updatedwebhook (POST /api/billing/webhookon 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 itended. 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’sbeforeCreateInvitationhook 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”) throughNotifications.announce, whatever the billing state. The Autumn customer is created with the organization (Billing.registerCustomer, from Better Auth’safterCreateOrganization), so it can be found in Autumn’s admin from the start. Attaching the hiddencompplan there grants free access (unlimited seats), shown ascomped; extending a trial or adding a coupon in Autumn works the same way, through the webhook. The plans are defined inautumn.config.tsand mirrored for display insrc/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. WithTELEMETRY_EMITKIT_API_KEY, the events whose category has a push channel go to that EmitKit project withnotifyon: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) andchurn(deleted accounts and organizations, including those deleted with their owner’s account). EmitKit is notifying its operator about EmitKit: the Worker callsapi.emitkit.com, which is itself, and a Custom Domain accepts that. Events that charge someone carryamountandcurrency, so the operator’s EmitKit dashboard shows the revenue. WithTELEMETRY_OPENPANEL_CLIENT_IDand…_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.tsreads Autumn’splan_changes; reported last, so a webhook that fails and is retried reports once),PublicApi(milestones) and the setup flows. Delivery runs after the response throughwaitUntiland 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 insrc/worker/assets.ts; the secret never leaves the Worker).web/ui/visits.tsreports 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 fromweb/ui/visits-page.tsunder 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 onlyutm_*andrefin 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(migration0006), 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.brandlooks 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 insrc/shared/domains.ts) are never looked up. A brand that’s found is kept inorganization_brand(migration0007): its square icon becomes the organization’slogounless 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 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. - Each route is registered once.
src/worker/router.tsrewrites the/api/v1/*and/api/openapi.jsonaliases to/v1/*and/openapi.jsonbefore the request reaches the router.HttpRouter.prefixedwasn’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
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>) |
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. |