# 3x-ui frontend React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles — `index.html` (admin panel SPA, all `/panel/*` routes), `login.html` (login + 2FA), and `subpage.html` (public subscription viewer). All three are built into `../web/dist/` and embedded into the Go binary via `embed.FS`. State is split between local `useState`, TanStack Query for server state, and `useTheme` / `useWebSocket` contexts. Form validation, API parsing, and the xray config model all run through a single shared Zod schema tree (see [Schemas](#schemas)). ## Dev ```sh npm install npm run dev ``` Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes proxy to the Go panel at `http://localhost:2053/`, so start the Go panel first (`go run main.go`) and then Vite. The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`, `/panel/xray` to the matching Vite-served HTML, so the sidebar's production-style links work without round-tripping through Go. ## Scripts | Command | What | |---|---| | `npm run dev` | Vite dev server with API + WS proxy to Go | | `npm run build` | Regenerates OpenAPI + Zod, then builds into `../web/dist/` | | `npm run preview` | Serve the built bundle locally | | `npm run typecheck` | `tsc --noEmit` (strict, no emit) | | `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) | | `npm run test` | Vitest single run (schema fixtures, link parsers, …) | | `npm run test:watch` | Vitest watch mode | | `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` | | `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` | CI runs `typecheck`, `lint`, `test`, and `build` on every PR (see `../.github/workflows/ci.yml`). ### One-off: scan for deprecated APIs Run this command to sweep the codebase for usages of APIs marked with the JSDoc `@deprecated` tag (AntD prop renames, Zod renames, removed Web APIs, etc.): ```sh npx eslint --config eslint.deprecated.config.js src ``` It's a type-aware ESLint run against `eslint.deprecated.config.js` and is not wired into `npm run lint` because typed linting triples the wall-clock time. ## Production build ```sh npm run build ``` Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under `assets/`). `manualChunks` splits AntD, icons, codemirror, and react-query into separate vendor bundles to keep the per-page initial JS small. The Go binary embeds this directory at compile time and `web/controller/dist.go` serves the per-page HTML. ## Layout ``` frontend/ ├── index.html, login.html, subpage.html # 3 Vite entries ├── tsconfig.json ├── eslint.config.js ├── eslint.deprecated.config.js # On-demand type-aware lint config that flags │ # usages of APIs marked with JSDoc @deprecated ├── vitest.config.ts ├── vite.config.js ├── scripts/ │ └── build-openapi.mjs # endpoints.ts → openapi.json └── src/ ├── entries/ # Per-page bootstrap (createRoot + render) ├── main.tsx # Shared root for the admin SPA (index.html) ├── routes.tsx # react-router routes mounted under /panel/ ├── pages/ # One folder per route, page component + helpers │ ├── index/, login/, inbounds/, clients/, xray/, nodes/, │ ├── settings/, api-docs/, sub/ ├── layouts/ # AdminLayout (sidebar + header + outlet) ├── components/ # Cross-page React components ├── hooks/ # useClients, useTheme, useWebSocket, … ├── api/ # Axios + CSRF interceptor, TanStack Query bridge, │ # WebSocket client + queryClient.ts ├── i18n/ # react-i18next init (locales in web/translation/) ├── lib/xray/ # Pure functions: link generation, defaults, │ # form ⇄ wire adapters, protocol capabilities ├── schemas/ # Zod source-of-truth (see "Schemas" below) ├── generated/ # Code-generated zod + ts types from Go │ # (DO NOT hand-edit — regenerated by gen:zod) ├── models/ # Thin legacy types still in transit │ # (DBInbound, Status, AllSetting, reality-targets) ├── styles/ # Shared CSS modules ├── test/ # Vitest specs + golden fixtures │ ├── *.test.ts │ ├── __snapshots__/ │ └── golden/fixtures/ # Per-(protocol × network × security) JSON └── utils/ # HttpUtil, ClipboardManager, SizeFormatter, … ``` ## Schemas `src/schemas/` is the single source of truth for the xray configuration model. Every API response is parsed through it, every form field is validated against it, and TypeScript types are inferred via `z.infer` — never hand-written. ``` schemas/ ├── primitives/ # Atomic reusable schemas (port, protocol, sniffing, …) ├── api/ # Backend response shapes (e.g. SlimInboundSchema) ├── forms/ # User-facing form shapes (narrower than api/) ├── protocols/ │ ├── inbound/ # Per-protocol settings (vmess, vless, trojan, …) │ ├── outbound/ │ ├── stream/ # Network transports (tcp, ws, grpc, xhttp, kcp, …) │ └── security/ # TLS, Reality, none ├── client.ts, dns.ts, routing.ts, setting.ts, status.ts, xray.ts └── _envelope.ts # Generic `Msg` envelope wrapper ``` Patterns: - **Discriminated unions** for polymorphic data — inbound `settings` is `z.discriminatedUnion('protocol', […])`, same for stream and security. - **Three validation layers**, non-overlapping: - API boundary: `parseMsg(msg, schema, ctx)` inside TanStack Query `queryFn` — warn-only in prod, throws in dev - Form input: `antdRule(schema.shape.field)` on every `` — blocks submit + per-field inline error - Wire request: `Schema.parse(payload)` inside `mutationFn` — throws, because a malformed payload here is always a developer bug - **No `.loose()` or `[key: string]: any`** in production schemas. `@typescript-eslint/no-explicit-any: error` is enforced. ## Form pattern (Pattern A) All non-trivial modals use this single pattern: ```tsx const [form] = Form.useForm(); const onFinish = async () => { const values = await form.validateFields(); await createInbound.mutateAsync(values); };
``` No `safeParse`-on-submit handlers, no `useRef` for form references, no inline `z.string().min(1)` in rules. Conditional fields use `` with the nested protocol schema. ## Testing Vitest runs everything under `src/test/`. Schemas have **golden fixture suites** — one JSON per `(protocol × network × security)` combination round-tripped through `schema.parse` → link generator → snapshot. Regenerate snapshots after intentional changes: ```sh npx vitest run -u ``` Fixtures live in `src/test/golden/fixtures/` and are auto-discovered via `import.meta.glob`. ## Adding a new page Most new routes go inside the admin SPA (`index.html`) via `routes.tsx` — no new HTML or Vite entry needed. 1. Add the page component under `src/pages//`. 2. Register it in `src/routes.tsx` under the `/panel/...` tree. 3. If you need a brand-new top-level bundle (login-style standalone page), add the HTML at `frontend/.html`, an entry at `src/entries/.tsx`, and register it in `rollupOptions.input` in `vite.config.js`. Then add the Go controller call to `serveDistPage(c, ".html")`.