The recipe model
A recipe is an additive manifest rewrite. It takes an existing
project's unibench.toml and adds a new service (or runtime, or
both) — typically pointing at a folder the user already created or
plans to create.
Recipes are deliberately not code generators. Applying
frontend-vite doesn't create a Vite project for you — it adds the
manifest declarations that wire one in, and leaves "actually have a
Vite project in ./frontend" as your problem.
Why "additive only"?
The two failure modes a code generator hits regularly are:
- Stale templates. Six months later, the recipe's bundled
package.jsonis three minor versions behind upstream and you carry someone else's outdated defaults forever. - Conflicting opinions. Your Vite app uses Vue + Pinia; the recipe assumes React + Tanstack Query. Now you fight the recipe instead of using it.
The additive model sidesteps both. Recipes declare how to run your existing project under Unibench, not what your project looks like.
Apply semantics
unibench recipes apply <recipe> [<project>] [--service-name <n>] [--cwd <p>]
reads the manifest with toml_edit (comments-preserving), merges
the recipe's TOML fragment, and writes the result back.
The merge:
- inserts new top-level entries (
[services.<n>],[runtime.<lang>]) - errors on service-name conflict — the existing service stays untouched; the manifest doesn't end up half-applied
- preserves the user's pinned runtime version on conflict (we'd
rather not silently bump someone's
[runtime.node]from20.11.0to22) - keeps every comment, blank line, and existing field order outside the mutation point
Service-name conflict is the one error users actually hit in
practice — same-folder twice, or two recipes both wanting to be
"frontend". --service-name admin solves it.
The v1 recipes
Two ship in v1:
frontend-vite— Vite dev server as aprocessservice.npm run dev -- --port $PORT, default cwd./frontend.frontend-nextjs— Next.js dev server with-p $PORTforwarding.
Both default to service name frontend and cwd ./frontend. Apply
the same recipe twice with different --service-name + --cwd to
land an admin app alongside the customer-facing one.
What a recipe is, mechanically
Internally a recipe is:
- a stable
nameand human-facingsummary/description tagsfor surfacing in UIs- a
default_service_nameanddefault_cwd - a TOML fragment template with
{{SERVICE_NAME}}and{{CWD}}placeholders that get substituted at apply time
The fragment is just TOML — it goes through the same parser as the project's manifest, so the same validation applies. A broken recipe fails at apply time, not silently at start time.
Adding new recipes
Recipes live in crates/core/src/recipes.rs. Adding one means
adding a Recipe to catalog() and shipping a new app build. v2 may
move them out-of-process (decision 2.17) but the v1 set is small
enough that hardcoding is the right call.
What recipes can't do
Recipes are intentionally bounded. They can't:
- modify existing service blocks (conflict → error)
- delete anything
- run scripts on apply
- prompt for user input beyond the CLI flags
If you want any of those, you probably want a seed script or a custom service-type, not a recipe.