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

Development

Local setup, checks, and where to add procedures, tables and bindings.

Prerequisites

  • Node 24+, pnpm 12.
  • A HeroUI Pro licence. @heroui-pro/react is a stub that downloads the licensed code during pnpm install. Run npx heroui-pro@latest login once, or set HEROUI_AUTH_TOKEN (a CI token from heroui.pro) in the environment of pnpm install. Never commit the downloaded artifacts.

First run

Conductor workspaces run scripts/conductor-setup.sh (from conductor.json): it links .dev.vars and .env.local from the main checkout, installs dependencies and migrates local D1. Otherwise:

pnpm install
pnpm exec wrangler d1 migrations apply DB --local    # local D1 in .wrangler/state
node -e 'const {generateKeyPairSync}=require("crypto");const {publicKey,privateKey}=generateKeyPairSync("ec",{namedCurve:"prime256v1"});const p=publicKey.export({format:"jwk"}),s=privateKey.export({format:"jwk"});const b=x=>Buffer.from(x,"base64url");console.log("VAPID_PUBLIC_KEY="+Buffer.concat([Buffer.from([4]),b(p.x),b(p.y)]).toString("base64url"));console.log("VAPID_PRIVATE_KEY="+s.d)'

Put VAPID_PRIVATE_KEY and a random BETTER_AUTH_SECRET (32+ characters) in .dev.vars. Put the printed VAPID_PUBLIC_KEY in wrangler.jsonc.

pnpm dev

To fill the local database, run pnpm db:seed:local (wrangler d1 execute DB --local --file=seed/local.sql, the D1 way to run SQL against local state). It gives dev@example.com an “Acme” workspace at /acme. The workspace runs four real products under their real URLs: Cal.com, Dub, Plausible and Excalidraw, with 12 channels between them. Its customers are made-up people at real companies (maya@linear.app, kenji@stripe.com, …). It has 300 events over 30 days, denser towards now, whose metadata carries real links: GitHub commits and pull requests, dub.sh short links, referrers, Stripe invoices. It also has USD and EUR revenue with refunds, and the API key emitkit_localdevseed. Brand icons for those domains appear once PARSEW_PUBLISHABLE_KEY is set in .dev.vars. That key exists only in local D1. Running the seed again refreshes the seeded events to the current time and leaves everything else alone. The file sits outside migrations/, so deploys never apply it.

URL Surface
http://localhost:5173 marketing site (only / renders in dev; other pages are prerendered at build)
http://app.localhost:5173 PWA, /api/auth/*, /rpc/*
http://api.localhost:5173 public API, /openapi.json, /mcp

To skip signing in, click Continue in local dev on the sign-in page. It signs you in as dev@example.com (“Local Dev”) and creates that user the first time. It lands on the seeded Acme workspace, or on getting-started if you haven’t seeded. The button and its endpoint (POST /api/auth/dev/sign-in) only exist under pnpm dev. Builds, including the production-like run below, answer 404.

*.localhost resolves to 127.0.0.1 in browsers and curl. Local email is captured by the Workers email simulator and logged by the dev server. Local events use the D1 store (EVENT_STORE=d1).

Checks

pnpm check        # Ultracite: oxlint (+ Effect plugin) and oxfmt
pnpm fix          # autofix
pnpm typecheck    # tsc for worker, web and node projects
pnpm build        # vite build (worker + PWA + site) + marketing prerender + docs (Blume) bundled at /docs
pnpm verify       # all three

All three must pass before a commit. The repository has no unit tests: behaviour is verified end to end (see e2e/README.md).

A production-like local run

vite dev runs the Worker through Vite’s module runner. To run the exact production bundle in workerd:

pnpm build
pnpm exec wrangler dev -c dist/emitkit/wrangler.json --port 8788 --persist-to .wrangler/state
# app: http://app.localhost:8788 (send Origin: http://app.localhost:5173 for auth calls)

Docs

docs is a separate Blume (Astro) site in its own workspace package. It is served at /docs on the site host.

pnpm docs:dev     # Blume dev server with live reload (the URL is printed; pages live under /docs)
pnpm docs:build   # static build to docs/dist; `pnpm build` runs it and copies it into the Worker's assets

Pages are Markdown or MDX under docs/content (navigation order in each folder’s meta.ts); docs/content/self-hosting holds this section. Changelog entries use type: changelog in content/changelog/. Write links as if the docs were mounted at the root (/getting-started); Blume adds /docs. The API reference is generated from openapi/openapi.json. oxfmt skips the MDX, because it breaks Blume’s ::: blocks and code groups.

Adding things

  • A dashboard procedure: add it to src/shared/rpc/contract.ts (Effect Schema input/output), implement it in the service under src/server/…, and wire it in src/worker/rpc.ts.
  • A table or column: write migrations/000N_name.sql. For Better Auth plugin changes, run pnpm exec tsx --tsconfig tsconfig.node.json scripts/generate-auth-schema.ts and copy the new DDL into a migration. Alchemy applies pending migrations on deploy. Locally, run pnpm db:migrate:local.
  • A binding: declare it in both alchemy.run.ts and wrangler.jsonc, type it in src/server/platform/env.ts, and wrap it in a platform service.

Patched dependencies

patches/@heroui__react@3.2.6.patch (applied by pnpm through patchedDependencies in pnpm-workspace.yaml) fixes HeroUI’s ErrorMessage inside dialogs. From react-aria-components 1.21, every Dialog provides a TextContext with only a description slot. HeroUI’s standalone ErrorMessage renders Text slot="errorMessage", which then throws Invalid slot "errorMessage". That crashed Settings whenever a Better Auth UI dialog showed a server error, such as a wrong password in “Delete account”. The patch renders a plain role="alert" element in that case. 06-account-pwa.spec.ts covers it. Drop the patch once a HeroUI release fixes it; pnpm fails the install if the patch no longer applies after a version bump.

Brand assets

The EmitKit brand kit (SVG masters, Geist fonts, colour and usage rules) is the source for every brand file. The web files are generated from its SVGs and committed:

Files Artwork
web/public/{app,www}/favicon.svg, favicon.ico (16/32/48), favicon-96x96.png, web-app-manifest-{192,512} standard app icon, rounded
apple-touch-icon.png, web-app-manifest-maskable-{192,512} standard app icon, square (iOS and maskable crops round it themselves)
web/public/app/badge-96x96.png white mark on transparent (Android notification badges use alpha only)
web/public/www/og-image.jpg the kit’s OpenGraph card; bump ?v= in web/www/head.ts when it changes
web/public/www/email-wordmark.png, logo-emitkit.png midnight wordmark (emails can’t show SVG)
docs/public/*, docs/fonts/* the same icons, both wordmarks, Geist

In code, use BrandMark, Wordmark and AppIcon from @ui/brand-mark. They carry the kit’s rules: blue or white artwork only, never the name typed next to the mark, and a wordmark at least 96px wide. Emit Blue (#0A31F5) is HeroUI’s --accent, and Geist / Geist Mono are self-hosted in web/ui/fonts.

Was this page helpful?