Monorepo Structure
EcoCtrl is a pnpm workspace with three apps and three packages. This page explains the non-obvious choices behind the layout — what is special about the vite dependency, why @ecoctrl/ui ships source files instead of a build, and how versions stay in sync.
Workspace layout
ecoctrl/
├── apps/
│ ├── admin/ # React 19 admin dashboard (Tab-based SPA)
│ ├── web/ # React Router 7 + Babylon.js public portal
│ └── docs/ # VitePress 2 documentation site
├── packages/
│ ├── server/ # Fastify 5 REST API
│ ├── ui/ # Shared component library (shadcn/ui style, source-only)
│ └── shared/ # Zod schemas, types, and Vite tooling
├── docker/ # Compose manifests and per-app Dockerfiles
├── scripts/ # start.sh and runtime helpers shipped with releases
└── pnpm-workspace.yamlpnpm-workspace.yaml declares both apps/* and packages/* as workspaces. Cross-package imports use the published name (@ecoctrl/ui, @ecoctrl/shared, etc.) and resolve to the local sources during development.
Catalog-pinned dependencies
The workspace uses pnpm catalogs to pin shared dependency versions in one place:
catalog:
"@base-ui/react": ^1.4.0
react: ^19.2.5
react-dom: ^19.2.5
tailwindcss: ^4.2.2
recharts: ^3.8.1
vite: npm:@voidzero-dev/vite-plus-core@^0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@latest
overrides:
vite: "catalog:"
vitest: "catalog:"Two things are worth highlighting:
viteis not Vite. It is aliased to@voidzero-dev/vite-plus-core— Voidzero's "Vite Plus" distribution. Every package that imports fromviteactually loads vite-plus. Theoverridessection forces the same alias even for transitive dependencies.- Adding or upgrading a shared dependency is a one-line change in
pnpm-workspace.yaml. Each app then references it with"react": "catalog:"in its ownpackage.json.
What is vite-plus?
vite-plus is a Voidzero-distributed superset of Vite that ships:
vp, an opinionated CLI bundlingdev,build,check,fmtandlint. Bothapps/adminandapps/webusevp devandvp buildinstead of plainvite.- Rolldown as the bundler, accelerating production builds.
- OXC for linting and formatting.
Because the API surface is compatible with Vite's, plugins such as @vitejs/plugin-react and @tailwindcss/vite continue to work unmodified.
Shared utilities (@ecoctrl/shared)
packages/shared exposes:
- Zod schemas under
types/api/— used by the server for request/response validation and by the frontends for type-safe fetch clients. Sharing the samez.infertypes ensures the contract cannot drift. createDevProxy(apiBaseUrl, options?)— returns a Viteserver.proxyblock that forwards/apiand/staticto the API only when the URL islocalhost. In production the rewrite happens at the reverse proxy layer instead.resolveUiAlias()— a Vite plugin that fixes@/imports across the package boundary; see below.viteConfig— the default base config used by every frontend app, wiring up TailwindCSS, sort-imports, lint, format and type checks.
The @ecoctrl/ui package — source-distributed
Most workspace UI libraries ship a compiled bundle under dist/. @ecoctrl/ui does not:
// packages/ui/package.json
{
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./index.css": "./src/index.css",
},
}The library exports cn, ThemeProvider, and a curated set of shadcn-style components built on top of Base UI. They are imported as TypeScript source and bundled by each consuming app.
Why a Vite plugin is needed
When apps/admin/src/somewhere.tsx imports from @ecoctrl/ui, that source still contains relative aliases like @/lib/utils. From admin's perspective @ points to admin's own src/, not to ui's. The resolveUiAlias() plugin in @ecoctrl/shared rewrites those aliases back to the ui package's own src/ and tries each TypeScript extension explicitly (Rolldown does not auto-probe extensions in production). Both apps/admin and apps/web already include it in their vite.config.ts.
The practical implication: whenever you edit something in packages/ui, the change is picked up by every consuming app on the next reload — no build step required.
Server build: Rolldown with auto-emitted dist/package.json
packages/server is bundled by Rolldown. Its config (rolldown.config.ts) externalizes every bare specifier and Node built-in, so the resulting dist/index.mjs is a thin entry point that imports from node_modules/.
A custom plugin scans the bundle's external imports, looks up each version from the source package.json, and emits a fresh dist/package.json listing only the runtime dependencies. The release zip therefore contains a self-contained server bundle that any host can install with a plain pnpm install --prod.
Versioning with Changesets
The repo uses Changesets for versioning. Two configuration choices are worth knowing:
- Fixed packages:
@ecoctrl/admin,@ecoctrl/weband@ecoctrl/servershare the same version number — bumping any one of them bumps all three. Release zip filenames are derived from this shared version. - Ignored packages:
@ecoctrl/uiand@ecoctrl/shareddo not participate in versioning. They evolve continuously alongside the apps that consume them.
Create a changeset before opening a PR with user-visible changes:
pnpm changeset
# pick the affected package(s), describe the change, commit the markdown.The release workflow (see Deployment) takes care of bumping versions, generating the changelog and publishing the release.
Path aliases recap
| App | Alias | Resolves to |
|---|---|---|
apps/admin | @/ | apps/admin/src/ |
apps/web | ~/ | apps/web/app/ |
apps/web | ~/components/ui | apps/web/app/components/ui (project shadcn copies) |
packages/server | @/ | packages/server/src/ |
If you encounter @/ inside a @ecoctrl/ui source file, that is the case resolveUiAlias() handles for you — do not change it.