Architecture
This page is the runtime view of EcoCtrl: how requests flow, where responsibilities live, and what each piece is built on. For the workspace layout and tooling, see Monorepo Structure.
High-level diagram
┌────────────────────────┐
│ Browser │
│ apps/web │ apps/admin │
└───────────┬────────────┘
│ /api /static
▼
┌──────────────────────────────────────┐
│ Reverse proxy (Caddy in Docker / │
│ lws --rewrite in release zips / │
│ Vite dev proxy in development) │
└───────────┬──────────────────────────┘
│
┌───────────▼──────────────┐
│ packages/server │
│ Fastify 5 + Zod │
│ ─ JWT + Refresh tokens │
│ ─ /api routes │
│ ─ /static/models/* fs │
│ ─ /documentation (Swagger)│
└───────────┬──────────────┘
│
┌───────────────────┼─────────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌──────────────────┐ ┌──────────────────┐
│ PostgreSQL │ │ Local file │ │ External IoT │
│ Drizzle │ │ uploads dir │ │ gateway (token │
│ ORM │ │ /static/models │ │ refreshing) │
└───────────┘ └──────────────────┘ └──────────────────┘The frontend bundles always issue requests against the literal /api and /static prefixes; the layer in front of them rewrites those to the real backend host. Changing the backend host or path prefix is a runtime config change, never a rebuild.
Frontend apps
apps/web — public 3D portal
- React Router 7 in framework mode
- Babylon.js scene (
apps/web/public/building.glb+useSettingsStore) - TailwindCSS v4
- Self-rolled i18n:
apps/web/app/locales/{en,zh}/*.jsonexposed through a Zustand store - Client only — server-side rendering is not used; the bundle is served as static files
apps/admin — internal dashboard
- React 19 SPA, tab-based routing (state in
App.tsx, not React Router) - Recharts for analytics, Base UI primitives for components
- Reads from
/api/*only; the seeded admin user is created on first run
Shared frontend conventions
- Path alias
@/(admin) and~/(web) point at each app's local source ~/components/ui(web) holds project-local shadcn copies, while shared library code lives in@ecoctrl/ui- Each app's
vite.config.tsextends@ecoctrl/shared'sviteConfigand registersresolveUiAlias()so@ecoctrl/uisource compiles inside the consumer
Backend (packages/server)
The server is a single Fastify 5 process started from packages/server/index.ts:
await fastify.register(databasePlugin);
await fastify.register(fastifyJwt, { secret, sign: { expiresIn: "15m" } });
await fastify.register(cors, { origin: process.env.CORS_ORIGIN?.split(",") || true });
await fastify.register(multipart, { limits: { fileSize: 100 * 1024 * 1024, files: 1 } });
await fastify.register(fastifyStatic, { root: "uploads/models", prefix: "/static/models/" });
await fastify.register(swagger, { ... }); // OpenAPI from Zod schemas
await fastify.register(swaggerUi, { routePrefix: "/documentation", ... });
await fastify.register(apiRoutes, { prefix: "/api" });Key facts:
- Type provider:
fastify-type-provider-zod— every route's body, querystring and response are validated with Zod schemas, and the same schemas drive the OpenAPI document. - Auth gate: a single
onRequesthook inroutes/index.tsrejects every/api/*request that doesn't carry a JWT, with an explicit allow-list (login, register, refresh, OAuth, public dashboard read). - Static serving: uploaded 3D models live on disk under
uploads/models/and are exposed at/static/models/*. - Database access: a Fastify plugin opens the Drizzle pool once and decorates
fastify.dbso every repository talks to the same connection. - Bootstrap:
ensureDatabase()andsyncSmtpFromEnv()run before the server listens — they ensure the database exists (when permitted) and copy SMTP credentials from.env.localinto the platform config row.
Layered code structure
packages/server/src/
├── routes/ # HTTP layer — validation, auth, error mapping
├── services/ # Cross-cutting workflows (e.g. IoT proxy, mail)
├── repositories/ # Database access functions (createXxx, findXxx)
├── schemas/ # Drizzle table schemas (one file per table)
├── plugins/ # Fastify plugins (database)
├── lib/ # Pure helpers (paths, mailer, ensureDatabase)
└── config/ # Environment-driven configurationRepository functions follow Prisma-style naming (createXxx, findManyXxx, findXxxByYyy) and return T | null rather than boolean. Routes never import Drizzle directly; they go through the repository layer.
Build pipeline
| Package | Tool | Output |
|---|---|---|
apps/web, apps/admin | vp build (vite-plus + Rolldown) | static SPA bundle |
packages/server | rolldown | dist/index.mjs + auto-emitted dist/package.json |
apps/docs | vitepress build | static site under .vitepress/dist/ |
packages/ui, packages/shared | none — consumed as source | n/a |
The server's Rolldown config externalizes every bare specifier and Node built-in. A custom plugin then walks the bundle's external imports, looks each version up in the source package.json, and writes a fresh dist/package.json listing only the runtime dependencies. The released zip can therefore be installed on any host with pnpm install --prod.
See Deployment for how these outputs are packaged into release zips and Docker images.
Runtime topologies
EcoCtrl supports three runtime shapes, all sharing the same compiled code:
Local development
node tsx --watch ──► Fastify (3000)
vite-plus dev ──► admin (5173)
vite-plus dev ──► web (8080)
vitepress dev ──► docs (5174)createDevProxy(API_BASE_URL) in @ecoctrl/shared returns a Vite proxy block that forwards /api and /static to the API only when the host is localhost — so deploying the same Vite config behind a real domain doesn't double-proxy.
Docker Compose (docker/compose.yml)
postgres:16-alpine :5432
ecoctrl-server (Node) :3000
ecoctrl-admin (Caddy) :4173 → /api /static rewritten to http://server:3000
ecoctrl-web (Caddy) :8081 → /api /static rewritten to http://server:3000Per-app Dockerfiles produce small images: the SPA bundle plus a Caddyfile that rewrites the API/static prefixes. The compose file mounts each app's .env.local so backend host and prefix are configurable without rebuilding.
Release zip (ecoctrl-all-vX.Y.Z.zip)
ecoctrl/
├── start.sh # interactive menu — start, restart, stop
├── server/dist/... # node bundle + auto-generated package.json
├── admin/... # static files
└── web/... # static filesstart.sh runs the server under pm2 (ecoctrl-server) and serves admin/ and web/ with local-web-server's --rewrite flag, replicating Caddy's behavior without requiring it. The same .env.local files drive the rewrites.
IoT proxy layer
Routes under /api/iot/* proxy a third-party gateway. The token-refresh logic is centralized:
iot_tokensrow stores the access/refresh pair and absolute expiry in milliseconds.- A service helper checks expiry on every outbound call, refreshes when needed, and persists the new pair.
- Clients call EcoCtrl, never the upstream — credentials never reach the browser.
Documentation site (apps/docs)
VitePress 2 with bilingual locales: English at root and 简体中文 at /zh/. Content lives under apps/docs/{guide,reference,zh} and the site is deployed to ecoctrl.godot.run via Cloudflare Workers Static Assets. Public read access to runtime stats is enabled by exposing only GET /api/dashboard on the public allowlist.