* feat(sub): parse generic Happ/INCY routing payloads for the JSON subscription Accepts the routing-rules format emitted for Happ and INCY (inline JSON, happ:// or incy:// deeplink, or a remote https:// URL resolved through the existing remote routing cache). The JSON subscription will bake these rules into its documents so header-ignoring clients still get routing. * feat(sub): bake Happ/INCY routing profiles into JSON subscription documents When subJsonRoutingRules is set, every emitted document (per-inbound and balancer alike) carries the profile's dns and routing rules baked in, so header-ignoring clients like Happ and INCY still get routing; the legacy simple-rules merge only applies when no profile is set. The balancer document builder keeps rewriting proxy-tag rules to the balancer. * feat(sub): add the subJsonRoutingRules setting Plumbed from the settings store through the subscription server into SubJsonService, so admins can set a routing profile once and every JSON subscription document carries it. * chore(api): regenerate OpenAPI artifacts for subJsonRoutingRules * feat(web): routing profile editor for the JSON subscription A textarea inside the JSON card accepts the routing profile (inline JSON, happ/incy deeplink, or https URL) with a remote-source badge; the badge helper moves to a shared module. Keys added to all 13 locales. * fix(sub): warm and lazily resolve the baked JSON routing source The routing profile was resolved once at service construction: a remote URL that was cold at that moment baked default routing forever, and the cron job never warmed it. The job now warms the subJsonRoutingRules URL, and the profile resolves per request with an in-memory memo (a failed resolve is not cached), so a warmed cache takes effect without a restart. * feat(sub): fall back to the JSON routing profile for the Routing header Happ and INCY download the geo files a routing profile references through the Routing response header. When the Happ header setting was blank the header stayed unset, and clients fetched no geo files even though a JSON routing profile was configured. A blank setting now falls back to the JSON profile: happ/incy deeplinks pass through, inline JSON and remote URLs are normalized to a happ:// deeplink; an unusable or oversized value leaves the header unset. Locale captions mention the fallback. * fix(sub): pass routingRules arg at call sites added by main Main gained four NewSubJsonService call sites after this branch forked; update them to the five-arg signature so internal/sub builds again. * fix(sub): address code review findings on the baked JSON routing The memoised baked template never invalidated, so an edited remote profile kept serving the superseded dns/routing subtrees until a panel restart; bakedTemplate now re-resolves the spec per request and rebuilds only when the payload actually changed (regression-tested). subJsonRoutingRules shared the happ persistence row with subRoutingRules, so only the last-written setting survived a restart; it now resolves under its own jsonhapp kind with the same validation and size caps. The setting also joins validateSettingsURLs, so remote values are canonicalised and bad URLs are rejected on save. Also: drop the unreachable half of the remote-source guard, cut the overlong comment blocks to the two-line convention, and deduplicate remoteSourceBadge in the General tab. Merges upstream/main (call sites for the widened NewSubJsonService signature). * style(sub): gofumpt the json_routing imports * fix(sub): accept happ add/ deeplinks and bound the routing warning The baked-JSON routing parser only recognised happ://routing/onadd/, but normalizeHappRouting treats happ://routing/add/ as an equally valid routing deeplink. An operator pasting the add/ form got the Routing header set, so the panel looked configured, while every JSON subscription document silently carried the default routing instead of their profile. resolveJsonRoutingSpec logged one warning per call and bakedTemplate calls it once per emitted document, so a single fetch of an unusable profile wrote one identical warning per document. On the public subscription server that floods the 10240-entry buffer the panel's log view reads, evicting real entries. Log only when the message changes, and reset on a successful resolve so a profile that recovers and fails again is still reported. Also resolve the template once in buildBalancerConfig: two resolves could straddle a profile refresh and pair one revision's dns with the other's routing.
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 ../internal/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).
Dev
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 ../internal/web/dist/ |
npm run preview |
Serve the built bundle locally |
npm run typecheck |
tsc --noEmit (strict, no emit) |
npm run lint |
oxlint over src/ + tools/ (.oxlintrc.json) |
npm run lint:deprecated |
Type-aware sweep for JSDoc @deprecated APIs (on demand) |
npm run format |
oxfmt (.oxfmtrc.json) — rewrites src/ + tools/ in place |
npm run format:check |
oxfmt in check mode (no writes) |
npm run test |
Vitest single run (schema fixtures, link parsers, …) |
npm run test:watch |
Vitest watch mode |
npm run storybook |
Storybook dev server on :6006 (component workbench + autodocs) |
npm run build-storybook |
Static Storybook build — CI compile-checks every story |
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, format:check, test, build, and
build-storybook 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.):
npm run lint:deprecated
It is oxlint's type-aware mode (oxlint-tsgolint, which drives the
TypeScript 7 typescript-go checker) narrowed to no-deprecated, and
is not wired into npm run lint because typed linting needs a full
type-check pass.
Production build
npm run build
Outputs to ../internal/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 internal/web/controller/dist.go serves the per-page HTML.
PWA mode
The login and panel pages expose a minimal network-only Progressive Web App.
The manifest, service worker, registration script, and icons are embedded with
the frontend and served under the runtime webBasePath. The service worker
does not use Cache Storage, does not intercept requests, and does not provide
offline access; panel authentication, API calls, and WebSocket traffic remain
normal network requests.
Layout
frontend/
├── index.html, login.html, subpage.html # 3 Vite entries
├── tsconfig.json
├── .oxlintrc.json # oxlint config (replaces the ESLint flat config)
├── .oxfmtrc.json # oxfmt config (Prettier-compatible settings)
├── tools/oxlint/
│ └── input-number-guard.mjs # oxlint JS plugin: the #6121/#6127 cleared-
│ # InputNumber guard (oxlint has no
│ # no-restricted-syntax)
├── vitest.config.ts
├── vite.config.js
├── .storybook/ # Storybook config (main.ts, preview.tsx)
├── 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 (+ co-located *.stories.tsx)
├── hooks/ # useClients, useTheme, useWebSocket, …
├── api/ # fetch client + CSRF handling, TanStack Query bridge,
│ # WebSocket client + queryClient.ts
├── i18n/ # react-i18next init (locales in internal/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)
├── 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<typeof X> — 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<T>` envelope wrapper
Patterns:
- Discriminated unions for polymorphic data — inbound
settingsisz.discriminatedUnion('protocol', […]), same for stream and security. - Three validation layers, non-overlapping:
- API boundary:
parseMsg(msg, schema, ctx)inside TanStack QueryqueryFn— warn-only in prod, throws in dev - Form input:
antdRule(schema.shape.field)on every<Form.Item>— blocks submit + per-field inline error - Wire request:
Schema.parse(payload)insidemutationFn— throws, because a malformed payload here is always a developer bug
- API boundary:
- No
.loose()or[key: string]: anyin production schemas.typescript/no-explicit-any: erroris enforced by oxlint.
Form pattern (Pattern A)
All non-trivial modals use this single pattern:
const [form] = Form.useForm<InboundFormValues>();
const onFinish = async () => {
const values = await form.validateFields();
await createInbound.mutateAsync(values);
};
<Form form={form} onFinish={onFinish}>
<Form.Item
name="port"
label="Port"
rules={[antdRule(InboundFormSchema.shape.port, t)]}
>
<InputNumber min={1} max={65535} />
</Form.Item>
</Form>
No safeParse-on-submit handlers, no useRef<any> for form
references, no inline z.string().min(1) in rules. Conditional
fields use <Form.Item dependencies={...} shouldUpdate> 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:
npx vitest run -u
Fixtures live in src/test/golden/fixtures/ and are auto-discovered
via import.meta.glob.
Storybook
Reusable components in src/components/ are developed and documented in
Storybook (@storybook/react-vite). It is a component workbench, not part
of the shipped panel — nothing here is embedded into the Go binary. The built
Storybook is published with the docs site at
docs.sanaei.dev/storybook by
.github/workflows/docs-deploy.yml.
npm run storybook # dev server on http://localhost:6006
npm run build-storybook # static build; CI runs this to compile-check every story
Addons: @storybook/addon-docs renders an autodocs page per component,
@storybook/addon-a11y flags accessibility issues in the canvas, and
@storybook/addon-vitest runs every story as a headless-browser test under
npm run test (Playwright/Chromium — run npx playwright install chromium once
locally). The .storybook/preview.tsx decorator wraps every story in the AntD
ConfigProvider and adds a light/dark theme toggle to the toolbar.
Conventions for a story:
- Co-locate it with its component as
<Component>.stories.tsx. - Set
tags: ['autodocs']so it gets a generated docs page. - Document props via story metadata, not JSDoc (the repo bans
//comments): a component summary inparameters.docs.description.componentand per-prop text inargTypes[prop].description.satisfies Meta<typeof Component>keeps the metadata type-checked.
Adding a new page
Most new routes go inside the admin SPA (index.html) via
routes.tsx — no new HTML or Vite entry needed.
- Add the page component under
src/pages/<page>/. - Register it in
src/routes.tsxunder the/panel/...tree. - If you need a brand-new top-level bundle (login-style standalone
page), add the HTML at
frontend/<page>.html, an entry atsrc/entries/<page>.tsx, and register it inrollupOptions.inputinvite.config.js. Then add the Go controller call toserveDistPage(c, "<page>.html").