feat(frontend): вернуть проверку типов SFC-шаблонов и закрыть все 142 ошибки

Vue 3.2.45 -> 3.5.42, TypeScript 4.9.3 -> 5.9.3, vue-tsc 0.35.0 -> 3.3.11.
Element Plus, Vue Router, Pinia, Vite и VueUse не тронуты: ни один из них не
является предусловием работающего typechecker.

vue-tsc 0.35 был не просто инертен — на Vue 3.5 он ломается сам (TS7026: нет
JSX.IntrinsicElements), потому что не знает vue/jsx-runtime. То есть проверка,
которая ничего не находила, ещё и не пережила бы обновление Vue.

Современный vue-tsc даёт 142 ошибки, а не ~155, и картина однороднее ожидаемой:
141 x TS18048 и 1 x TS2322, всё в двух файлах представления Hysteria.
Предсказанного класса DefaultRow/PeerVo в таблице пиров не оказалось вовсе.

Все 142 — одно и то же: шаблон обращается к необязательным секциям конфига
(dataForm.tls.cert, dataForm.acme.dns.config, dataForm.resolver.https.sni).
Необязательны они правильно: так устроен upstream YAML. Инвариант «секция есть
всегда» существовал, но держался на порядке присваиваний внутри компонента.

Закрыто одним преобразованием на границе API вместо 141 `?.` или `as any`:
api/config/hysteriaViewModel.ts даёт Hysteria2ServerConfigView, где присутствие
каждой секции — свойство типа, и normalizeHysteriaViewModel(). types.ts
остаётся описанием того, что приходит по сети.

Побочно закрыто мёртвое UI: редактор outbounds (кнопка «+», диалог создания,
удаление тегов, emit update:outbounds) не мог ничего сохранить — страница
отрисована с :disabled="true", родитель передаёт :outbounds без v-model, а
маршрутов записи серверного конфига в API нет. Оператор мог добавить outbound и
уйти в уверенности, что изменил конфигурацию сервера.

package.json: typecheck и build:prod разделены, verify запускает их по порядку.
Раньше `vite build && vue-tsc` сначала тратил время на production bundle и
только потом сообщал о типовой ошибке.
This commit is contained in:
2026-08-30 07:04:28 +05:00
parent cf094f6e6f
commit 02ea33520d
9 changed files with 726 additions and 730 deletions
@@ -0,0 +1,314 @@
import type {
Hysteria2ServerConfig,
Hysteria2ServerConfigOutbound,
} from "./types";
/**
* Нормализация конфига Hysteria на границе API.
*
* Зачем этот файл существует.
*
* `Hysteria2ServerConfig` описывает то, что РЕАЛЬНО приходит по сети, и почти
* все его секции необязательны — потому что необязательны они и в upstream
* YAML. Панель при этом показывает их как обычную форму: `dataForm.tls.cert`,
* `dataForm.acme.dns.config`, `dataForm.resolver.https.sni`.
*
* Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
* Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` в двух файлах — и он
* прав: обращение через возможно отсутствующий объект в рантайме падает.
* Спасало только то, что форма строится merge'ем поверх полного объекта
* значений по умолчанию, то есть инвариант «секция есть всегда» существовал,
* но держался на порядке присваиваний внутри компонента и нигде не был
* выражен типом.
*
* Два способа это закрыть неверны:
*
* `?.` в 141 месте шаблона — прячет вопрос «а что показывать, если секции
* нет», не отвечая на него, и делает шаблон нечитаемым;
*
* `as any` — выключает ровно ту проверку, ради которой обновлялся
* typechecker.
*
* Здесь выбран третий: одно преобразование на входе. Ответ приходит в
* `Hysteria2ServerConfig` (как есть, с необязательными секциями), а форма
* работает с `Hysteria2ServerConfigView`, где присутствие каждой секции —
* свойство типа. Шаблону больше не нужно знать ни одного нюанса
* необязательности upstream-схемы.
*
* Важно, чего этот слой НЕ делает: он не участвует в экспорте. Выгрузка
* серверного конфига идёт на backend от исходного YAML и сохраняет поля, о
* которых HY2XS ещё не знает (см. docs/04). View-модель — только для
* отображения, поэтому потеря неизвестных полей здесь безвредна.
*/
/**
* DeepRequired делает обязательными все поля на всех уровнях.
*
* Массивы обрабатываются отдельно: без этой ветки `T[]` попал бы в `object` и
* маппинг прошёлся бы по свойствам самого массива.
*/
type DeepRequired<T> = T extends (infer U)[]
? DeepRequired<U>[]
: T extends object
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
: T;
/** Конфиг Hysteria в том виде, в котором его показывает панель. */
export type Hysteria2ServerConfigView = DeepRequired<Hysteria2ServerConfig>;
/** Один outbound в том же виде. */
export type Hysteria2ServerConfigOutboundView =
DeepRequired<Hysteria2ServerConfigOutbound>;
/**
* Полное значение по умолчанию: каждая секция заполнена.
*
* Тип здесь не декоративный. `Hysteria2ServerConfigView` требует все поля, и
* добавление секции в `Hysteria2ServerConfig` сломает компиляцию ровно здесь —
* то есть новое поле upstream нельзя молча не отобразить.
*/
export const defaultHysteria2ServerConfigView: Hysteria2ServerConfigView = {
listen: ":443",
tls: {
cert: "",
key: "",
sniGuard: "",
clientCA: "",
},
ech: {
keyPath: "",
},
acme: {
domains: [],
email: "",
ca: "letsencrypt",
listenHost: "0.0.0.0",
dir: "/var/lib/hysteria/acme",
type: "",
http: {
altPort: 8888,
},
tls: {
altPort: 44333,
},
dns: {
name: "cloudflare",
config: {},
},
disableHTTP: false,
disableTLSALPN: false,
altHTTPPort: 80,
altTLSALPNPort: 443,
},
obfs: {
type: "gecko",
salamander: {
password: "",
},
gecko: {
password: "",
minPacketSize: 512,
maxPacketSize: 1200,
},
},
quic: {
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnReceiveWindow: 20971520,
maxConnReceiveWindow: 20971520,
maxIdleTimeout: "30s",
maxIncomingStreams: 1024,
disablePathMTUDiscovery: false,
disableStatelessReset: false,
},
bandwidth: {
up: "50 mbps",
down: "50 mbps",
disableLossCompensation: false,
},
congestion: {
type: "bbr",
bbrProfile: "standard",
},
ignoreClientBandwidth: false,
speedTest: false,
disableUDP: false,
udpIdleTimeout: "60s",
resolver: {
type: "",
tcp: {
addr: "8.8.8.8:53",
timeout: "4s",
},
udp: {
addr: "8.8.4.4:53",
timeout: "4s",
},
tls: {
addr: "1.1.1.1:853",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
https: {
addr: "1.1.1.1:443",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
},
sniff: {
enable: true,
timeout: "2s",
rewriteDomain: false,
tcpPorts: "80,443,8000-9000",
udpPorts: "all",
},
acl: {
file: "",
inline: [],
geoip: "",
geosite: "",
geoUpdateInterval: "168h",
},
outbounds: [],
trafficStats: {
listen: ":9999",
},
masquerade: {
type: "",
file: {
dir: "",
},
proxy: {
url: "",
rewriteHost: true,
insecure: false,
xForwarded: false,
},
string: {
content: "hello stupid world",
headers: {},
statusCode: 200,
},
listenHTTP: ":80",
listenHTTPS: ":443",
forceHTTPS: true,
},
mimic: {
enabled: false,
interface: "",
xdpMode: "",
path: "",
extraArgs: [],
},
realm: {
stunServers: [],
stunTimeout: "",
punchTimeout: "",
heartbeatInterval: "",
insecure: false,
ipMode: "",
portMapping: {
enabled: false,
timeout: "",
lifetime: "",
},
},
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" && value !== null && !Array.isArray(value)
);
}
/**
* Рекурсивное наложение ответа сервера на значение по умолчанию.
*
* `null` и `undefined` игнорируются намеренно: в YAML отсутствующая секция и
* секция со значением `null` означают одно и то же — «не задано», — и обе
* обязаны оставить значение по умолчанию, а не обнулить поле формы.
*
* Массивы заменяются целиком, а не сливаются поэлементно: список ACL-правил
* или outbounds с сервера — это весь список, а не патч к дефолтному.
*/
function mergeInto(target: Record<string, unknown>, source: unknown): void {
if (!isPlainObject(source)) {
return;
}
for (const [key, value] of Object.entries(source)) {
if (value === null || value === undefined) {
continue;
}
if (Array.isArray(value)) {
target[key] = value;
continue;
}
if (isPlainObject(value)) {
const existing = target[key];
if (!isPlainObject(existing)) {
target[key] = {};
}
mergeInto(target[key] as Record<string, unknown>, value);
continue;
}
target[key] = value;
}
}
function cloneDefaults(): Hysteria2ServerConfigView {
// structuredClone есть во всех целевых браузерах и, в отличие от
// JSON.parse(JSON.stringify(...)), не тратит проход на сериализацию.
return structuredClone(defaultHysteria2ServerConfigView);
}
/**
* Приводит ответ сервера к модели, с которой работает форма.
*
* Пустой или отсутствующий ответ даёт полное значение по умолчанию: это то же
* состояние, в котором форма находится до первого запроса.
*/
export function normalizeHysteriaViewModel(
raw: Hysteria2ServerConfig | null | undefined
): Hysteria2ServerConfigView {
const view = cloneDefaults();
mergeInto(view as unknown as Record<string, unknown>, raw);
return view;
}
/** Значение по умолчанию для одного outbound. */
export const defaultHysteria2ServerConfigOutboundView: Hysteria2ServerConfigOutboundView =
{
name: "",
type: "socks5",
socks5: {
addr: "",
username: "",
password: "",
},
http: {
url: "",
insecure: false,
},
direct: {
mode: "auto",
bindIPv4: "",
bindIPv6: "",
bindDevice: "",
fastOpen: false,
},
};
/**
* Тот же приём для одного outbound: список приходит с необязательными
* подблоками, а карточка показывает их как обычные поля.
*/
export function normalizeOutboundViewModel(
raw: Hysteria2ServerConfigOutbound | null | undefined
): Hysteria2ServerConfigOutboundView {
const view = structuredClone(defaultHysteria2ServerConfigOutboundView);
mergeInto(view as unknown as Record<string, unknown>, raw);
return view;
}
+11 -145
View File
@@ -19,6 +19,17 @@ export interface ConfigUpdateDto {
configUpdateDtos: Array<ConfigsUpdateDto>;
}
/**
* Конфиг Hysteria в том виде, в котором он ПРИХОДИТ ПО СЕТИ.
*
* Необязательность секций здесь не оплошность: ровно так устроен upstream YAML,
* и модель обязана его повторять, иначе она врала бы об ответе сервера.
*
* Форма панели работает не с этим типом, а с `Hysteria2ServerConfigView` из
* `hysteriaViewModel.ts`, где все секции обязательны. Значения по умолчанию
* живут там же: пока они лежали здесь, рядом с описанием ответа сервера, они
* выглядели частью протокола, хотя относятся исключительно к отображению.
*/
export interface Hysteria2ServerConfig {
listen: string;
tls?: {
@@ -169,129 +180,6 @@ export interface Hysteria2ServerConfig {
};
}
export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
listen: ":443",
tls: {
cert: "",
key: "",
sniGuard: "",
},
acme: {
domains: [],
email: "",
ca: "letsencrypt",
listenHost: "0.0.0.0",
dir: "/var/lib/hysteria/acme",
type: "",
http: {
altPort: 8888,
},
tls: {
altPort: 44333,
},
dns: {
name: "cloudflare",
config: {},
},
disableHTTP: false,
disableTLSALPN: false,
altHTTPPort: 80,
altTLSALPNPort: 443,
},
obfs: {
type: "gecko",
gecko: {
password: "",
minPacketSize: 512,
maxPacketSize: 1200,
},
},
quic: {
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnReceiveWindow: 20971520,
maxConnReceiveWindow: 20971520,
maxIdleTimeout: "30s",
maxIncomingStreams: 1024,
disablePathMTUDiscovery: false,
disableStatelessReset: false,
},
bandwidth: {
up: "50 mbps",
down: "50 mbps",
disableLossCompensation: false,
},
congestion: {
type: "bbr",
bbrProfile: "standard",
},
ignoreClientBandwidth: false,
speedTest: false,
disableUDP: false,
udpIdleTimeout: "60s",
resolver: {
type: "",
tcp: {
addr: "8.8.8.8:53",
timeout: "4s",
},
udp: {
addr: "8.8.4.4:53",
timeout: "4s",
},
tls: {
addr: "1.1.1.1:853",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
https: {
addr: "1.1.1.1:443",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
},
sniff: {
enable: true,
timeout: "2s",
rewriteDomain: false,
tcpPorts: "80,443,8000-9000",
udpPorts: "all",
},
acl: {
file: "",
inline: [],
geoip: "",
geosite: "",
geoUpdateInterval: "168h",
},
outbounds: [],
trafficStats: {
listen: ":9999",
},
masquerade: {
type: "",
file: {
dir: "",
},
proxy: {
url: "",
rewriteHost: true,
insecure: false,
xForwarded: false,
},
string: {
content: "hello stupid world",
headers: {},
statusCode: 200,
},
listenHTTP: ":80",
listenHTTPS: ":443",
forceHTTPS: true,
},
};
export interface Hysteria2ServerConfigOutbound {
name: string;
type: string;
@@ -313,28 +201,6 @@ export interface Hysteria2ServerConfigOutbound {
};
}
export const defaultHysteria2ServerConfigOutbound: Hysteria2ServerConfigOutbound =
{
name: "",
type: "socks5",
socks5: {
addr: "",
username: undefined,
password: undefined,
},
http: {
url: "",
insecure: false,
},
direct: {
mode: "auto",
bindIPv4: undefined,
bindIPv6: undefined,
bindDevice: undefined,
fastOpen: false,
},
};
export interface Tab {
name: string;
desc: string;