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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user