Getting started
Architecture
uf is an all-in-one toolchain, which is the same shape as create-react-app. This page is the list of things uf will not do, and where it does not yet comply with its own rules.
What went wrong with create-react-app
CRA was not a bad tool. It was the right design for 2016 that could not keep up with what React applications came to need, and the way it failed is close to a specification for how uf could fail.
react-scripts hid webpack, Babel, ESLint, Jest and PostCSS behind one
dependency. That was its whole appeal and it worked — right up until you needed
one webpack plugin it had not anticipated. Then the only route was eject,
which wrote the entire hidden configuration into your repository and could not
be undone. Between zero config and fully customisable there was nothing.
The second failure was structural. Every upgrade in the ecosystem had to travel through one package:
ecosystem change → react-scripts → you
A vulnerability in a transitive dependency could not be fixed by updating it;
you waited for a react-scripts release. One package serialised the upgrade
path of everything it contained.
The third is the one React's own deprecation notice leads with: CRA solved the build and stopped. Routing, data fetching and code splitting turned out not to be three problems but one — a router that knows a route's data can fetch the code and the data in parallel instead of discovering the second after the first has rendered. CRA could not integrate them, so every production application built its own framework on top of CRA, which is the problem CRA existed to remove.
The one-line lesson:
Abstraction without composability eventually becomes a bottleneck.
uf's position
uf owns orchestration, not implementation. It decides what runs, when, and how the pieces connect. It does not own the bundler, the dev server, the formatter or the runtime.
Unified interface, federated implementation.
Those are different things, and assuming they must match is what turns an
integrated tool into a monolith. uf dev, uf build, uf test is an
all-in-one experience; underneath, Vite is Vite and the Flow parser is Meta's.
This is the target, and uf does not meet it today: the formatter and the test runner are uf's own. That is deliberate — a Flow-aware version of neither existed, and "orchestrate the provider that does not exist" is not a plan — but it is an exception rather than a revision of the principle, and the audit below treats it as one. An exception stays an exception by staying replaceable.
The red lines
- Never fork Vite or Rolldown behaviour unless it is unavoidable.
- Never mirror the complete configuration schema of an upstream tool.
- Every built-in provider must be replaceable.
- No
ejectcommand. - No single package controls ecosystem dependency versions.
- Core must not depend on a specific JavaScript runtime.
- All orchestration must be inspectable.
- Provider-specific functionality must remain accessible.
- Defaults are conveniences, never architectural requirements.
- uf's core owns the graph, not the tools.
The continuum that replaces eject
convention → configuration → provider replacement → raw provider API
Each step is a smaller decision than the one before it, and none is a door that locks behind you. A project that needs one Vite plugin adds one Vite plugin. It does not inherit a build system.
Three runtimes, not one
"Runtime" is three questions, and collapsing them is how a toolchain ends up unable to target anything new:
| Orchestration host | where uf itself runs — a native binary |
| Plugin runtime | where JavaScript plugins run — Node.js, Bun, Deno |
| Target runtime | where the output runs — a browser, a worker, a server |
All three differ in an ordinary build: a native binary driving Vite on Node.js to produce a bundle for a Cloudflare Worker.
Where uf does not yet comply
A list of rules with no audit against it is decoration.
Mirroring Vite's schema (2), half closed. The vite key in uf.config.js
is merged over what uf generates, so a Vite option uf has never heard of is
reachable — that was the part of the react-scripts failure that actually
stranded people. What remains: dev and build still re-declare Vite's options
one at a time, and every one of those is a second name for a setting that
already has one.
Replaceability, met for one provider (3). fmt.nonFlow.formatter picks who
formats the JSON, CSS and TypeScript uf's Flow printer must not touch — Biome,
Prettier, or nobody — and uf runs the binary the project already has rather than
linking one in, translating its own settings into that provider's vocabulary.
uf explain fmt names the provider and the command. The lint engine, the
formatter's parser, the task runner and the package resolver are still
one-variant enumerations.
Lockstep versions (5). Every @uniflowed/* package pins its siblings
exactly and one script sets them all. That is right while the packages are one
thing, and it is the beginning of the chokepoint. Adapters need to move
independently before 1.0.
Inspectability, met (7). uf inspect --json prints the resolved
configuration, and uf explain <command> names the provider for every stage of
dev, build, test, fmt, lint and check — which binary runs it and
what it does. A stage that cannot be explained is a stage that should not
exist.
uf owns two tools (10). The formatter and the test runner are uf's own implementations rather than orchestrated providers — a deliberate choice, since a Flow-aware version of neither existed. Each has to stay replaceable, or it becomes the thing this page is about.
How the open lines get closed
Naming a violation is the easy half, and "we should fix that" is how a red line becomes a permanent footnote. Each one has an exit criterion:
| Line | Closed when |
|---|---|
| 2 — Vite's schema | a test fails on any config key whose only consumer is viteConfig() |
| 3 — replaceability | fmt.nonFlow.formatter |
| 5 — lockstep versions | a sibling dependency is a range, and one package can be released without the rest |
| 10 — the two owned tools | uf fmt and uf test resolve through the same seam as the bundler |
Not on the table
Each of these sounds reasonable and is a step toward the thing this page is about.
- An
ejectcommand, in any spelling — including a "print the effective Vite config so you can copy it" flag, which becomes one in practice. - A uf-shaped name for something Flow already names.
@noflowis how a file says it is plain JavaScript; acheck.excludelist would have been a second answer to a question that has one. - Vendoring a provider to make it fit. Upstream or a plugin, never a fork.
The failure no red line prevents
CRA's third failure was not architectural. It solved the build and stopped, and every production application built its own framework on top of the tool that existed to remove that work.
Nothing above prevents that. It is prevented by uf being a framework as well as a toolchain — the router, the RSC graph, server actions, the data layer — and by those being answers uf ships rather than integration points it documents. The audit is about not becoming a bottleneck; this is about being worth using at all.
The full record, including the reasoning, is
docs/red-lines.md.