Auch verfügbar in: 🇩🇪 Deutsch

Architecture (for contributors)

Inhalt

🇩🇪 Deutsch

How Mesh is built internally. Useful if you want to submit PRs, build your own widgets, or are just curious.

High-level overview

┌──────────────────────────────────────────────────────────┐
│                    Browser (Next.js Client)              │
│  React 19 · Tailwind v4 · Radix UI · React Query v5      │
└──────────────────┬───────────────────────────────────────┘
                   │ tRPC v11 (HTTP) + JSON
┌──────────────────▼───────────────────────────────────────┐
│                  Next.js Server (Node 22)                 │
│  ┌────────────────────────────────────────────────────┐  │
│  │ tRPC Router       Auth Strategy    Healthcheck    │  │
│  │ /api/widgets/*    open/token/      Scheduler      │  │
│  │                   userPwd/oauth2   p-limit(8)     │  │
│  └────────────────────────────────────────────────────┘  │
│  ┌────────────────────────────────────────────────────┐  │
│  │ Config Store: zod + proper-lockfile + Migrations   │  │
│  └────────────────────────────────────────────────────┘  │
└─────┬─────────┬──────────┬──────────┬─────────┬─────────┘
      │         │          │          │         │
      ▼         ▼          ▼          ▼         ▼
   /data/    Glances   HomeAssistant UniFi   Portainer …
config.json   :61208    REST + Token  API     API-Key

Folder structure

apps/web/
├── src/
│   ├── app/                  ← Next.js App Router (routes + pages)
│   │   ├── page.tsx          ← Dashboard /
│   │   ├── services/         ← /services
│   │   ├── boards/[slug]/    ← /boards/<slug>
│   │   ├── admin/            ← Admin section
│   │   └── api/widgets/      ← Server-side widget endpoints
│   ├── components/
│   │   ├── layout/           ← DashboardHeader, EditModeBar, …
│   │   ├── services/         ← ServiceGrid, ServicesBrowser, PinnedSection
│   │   ├── widgets/          ← 19 widget components + registry.tsx
│   │   ├── infra/            ← InfraNodeGrid, InfraNodeCard
│   │   ├── grid/             ← BoardGrid (react-grid-layout)
│   │   └── ui/               ← Modal, Toast, SortableList, …
│   ├── server/
│   │   ├── config/           ← schema.ts, migrations.ts, store.ts, defaults.ts
│   │   ├── auth/             ← Strategy pattern for 4 modes
│   │   ├── healthcheck/      ← Scheduler + Pinger
│   │   └── trpc/             ← Router + Procedures
│   └── hooks/                ← useLocalSearch, useEditMode, …
├── public/                   ← Static assets (logos, brand PNG)
└── package.json

Data flow example: a Glances widget

  1. User opens dashboard → Next.js renders app/page.tsx server-side
  2. Hydration → client mounts React, React Query starts polls
  3. Widget mounteduseQuery on trpc.widgets.glances.list
  4. tRPC procedure on the server → reads config.integrations.glances[]
  5. Per Glances instancefetch('http://glances-host:61208/api/4/all')
  6. Response aggregated → JSON back to client
  7. React Query caches, new poll after refreshSec

Schema layer

zod is the single source of truth:

  • ConfigSchema (schema.ts) defines the entire shape
  • TypeScript types via z.infer<typeof ConfigSchema>
  • On every writeConfig: schema validation
  • On every readConfig: schema parse + migration chain

config.json is synchronised with proper-lockfile (atomic writes, OS file lock). Important in the standalone build: proper-lockfile must be listed in serverExternalPackages in next.config.mjs.

React Query as live layer

No WebSocket, no browser-side SSE. Everything is polling via tRPC with:

  • refetchInterval (default 10–60 s depending on widget)
  • refetchOnWindowFocus: true
  • staleTime just below refetchInterval

This means no sticky state between tabs, no WebSocket issues behind reverse proxies, no auth state in the socket.

Exception: ESPHome v3 uses server-side SSE (node:http) — but as a one-shot probe, not as a browser stream.

Widget registry

apps/web/src/components/widgets/registry.tsx is the single point for:

  • kind string → Component
  • defaultSize / minSize / maxSize (24×20 grid)
  • wideThreshold — column count from which the wide layout is shown
  • supportsCompact — can the widget show a mini-tile variant?

Adding your own widget:

  1. Write apps/web/src/components/widgets/MyWidget.tsx (props: WidgetRenderProps)
  2. Add an entry in registry.tsx with kind, displayName, etc.
  3. Add the new kind to the WidgetKind enum (schema.ts)
  4. No schema bump needed — new widget instances can simply use the new kind

Auth layer

apps/web/src/server/auth/index.ts exports getAuthStrategy(mode). Each strategy implements:

interface AuthStrategy {
  isPublicRoute(path: string): boolean;
  authenticate(req: Request): Promise<{ user?: AuthUser, role: Role }>;
}

Routes are checked via adminProcedure (tRPC middleware). The OAuth2 strategy uses openid-client@6 + jose@6 for JWKS validation.

Tests

42+ Vitest tests. Organised per layer:

  • tests/auth/ — OIDC flow, group mapping, JWT validation
  • tests/healthcheck/ — pinger logic
  • tests/migrations/ — migration chain round-trip

Build

Multi-stage Dockerfile:

  1. deps — pnpm install
  2. buildpnpm build (Next.js standalone output)
  3. runtime — copy .next/standalone + .next/static + public + entrypoint

Standalone output bundles all dependencies — the final container has NO node_modules/.

Roadmap ideas

  • Optional WebSocket for sub-1-second live updates (today: 10 s polling)
  • Plugin architecture with dynamic widget loading from separate repos
  • Federated boards across multiple Mesh instances via Tailscale/WireGuard