ddd1020a3a
Решение «Vite не трогать» пересмотрено по данным, а не по желанию обновиться.
`pnpm audit --prod` (то, на что смотрит gate сборки) был чист, но полный audit
показывал в build-цепочке два critical и rollup GHSA DOM clobbering — а он
затрагивает ГЕНЕРИРУЕМЫЙ bundle, то есть уезжает в production. Vite 4.3.1 тянул
rollup 3 без исправления.
Взята 7.3.6 — последняя линия до Rolldown. Vite 8 по-прежнему не берётся: это
смена бандлера, отдельная работа с собственной приёмкой.
Вместе с Vite обновлена цепочка плагинов (@vitejs/plugin-vue 4 -> 6,
unplugin-* и unocss с версий 2022-2023 годов) и линтеры (@typescript-eslint
5 -> 8, prettier 2 -> 3, stylelint 15 -> 17). Новый @typescript-eslint нашёл 7
настоящих замечаний — исправлены по существу, а не подавлением правил; среди
них подавление несуществующего правила ban-types и `{}` вместо `object`.
Отдельно — три мёртвых редактора. Outbounds уже был разобран, здесь то же
самое для ImputMultiple (ACME-домены, inline ACL) и MapAdd (параметры ACME DNS,
заголовки masquerade): оба редактировали конфиг Hysteria в форме с
:disabled="true", получали значения без v-model и эмитили события, которых
никто не слушает, на странице без единого маршрута записи.
Цена ImputMultiple была измеримой. vuedraggable поставляется UMD-сборкой,
поэтому её require("vue") разрешался в vue/dist/vue.cjs.prod.js — полную сборку
Vue с рантайм-компилятором шаблонов. В бандл уезжало ~500 КБ исходников
(vuedraggable + sortablejs + compiler-core + compiler-dom) ради перетаскивания
тегов в недоступной для редактирования форме.
Разбор bundle через sourcemap нашёл и вторую потерю: @vueuse/core собирался
дважды — наш 13.9.0 и 14.4.0 из element-plus. Версии сведены.
bundle: 2 813 525 против 2 404 202 на исходной базовой линии (+17% за Vue 3.5,
Element Plus 2.14, echarts 6 и rollup 4). Промежуточное состояние без этих двух
исправлений было 3 046 172.
pnpm audit --prod: чисто. typecheck/eslint/stylelint: 0. go build с dist: OK.
313 lines
10 KiB
Go
313 lines
10 KiB
Go
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;
|
||
}
|