From 7442486a585ac60ded07e40cfd3b34c3769974dd Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 12:46:54 +0200 Subject: [PATCH] feat(frontend): HeaderMapEditor reusable component + wire WS/HTTPUpgrade headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a single reusable header-map editor that handles the two wire shapes Xray uses: - v1: { name: 'value' } — used by WS / HTTPUpgrade / Hysteria masquerade. One value per name. - v2: { name: ['value1', 'value2'] } — used by TCP HTTP camouflage. Each header can repeat (RFC 7230 §3.2.2). Internal state is always a flat list of {name, value} rows regardless of mode; conversion to/from the wire shape happens at the value / onChange boundary so consumers bind straight to a Form.Item with no extra transforms. Wired into: - InboundFormModal: WS Headers, HTTPUpgrade Headers - OutboundFormModal: WS Headers, HTTPUpgrade Headers XHTTP headers are already in a list-of-rows wire shape (different from these two), so they keep their bespoke editor. Hysteria masquerade is still deferred until the Hysteria stream sub-form lands. --- frontend/src/components/HeaderMapEditor.tsx | 122 ++++++++++++++++++ .../src/pages/inbounds/InboundFormModal.tsx | 13 ++ frontend/src/pages/xray/OutboundFormModal.tsx | 13 ++ 3 files changed, 148 insertions(+) create mode 100644 frontend/src/components/HeaderMapEditor.tsx diff --git a/frontend/src/components/HeaderMapEditor.tsx b/frontend/src/components/HeaderMapEditor.tsx new file mode 100644 index 00000000..f199bbd8 --- /dev/null +++ b/frontend/src/components/HeaderMapEditor.tsx @@ -0,0 +1,122 @@ +import { useMemo } from 'react'; +import { Button, Input, Space } from 'antd'; +import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; + +import InputAddon from '@/components/InputAddon'; + +// Reusable header-map editor. Handles the two wire shapes Xray uses for +// HTTP-style header maps: +// +// v1: { 'Content-Type': 'application/json', 'X-Custom': 'value' } +// Used by WS / HTTPUpgrade / Hysteria masquerade. One value per +// name. +// +// v2: { 'Accept': ['text/html', 'application/json'], +// 'X-Forwarded': ['1.2.3.4'] } +// Used by TCP HTTP camouflage request/response. Each header can +// repeat (RFC 7230 §3.2.2). +// +// Internal state is always the flat list-of-rows shape regardless of +// mode. Conversion to/from the wire shape happens at the value/onChange +// boundary so consumers can bind straight to a Form.Item without any +// extra transforms on their side. + +export type HeaderMapMode = 'v1' | 'v2'; + +export type HeaderMapValue = + | Record + | Record + | undefined; + +interface HeaderRow { + name: string; + value: string; +} + +interface HeaderMapEditorProps { + mode: HeaderMapMode; + value?: HeaderMapValue; + onChange?: (next: Record | Record) => void; +} + +function mapToRows(value: HeaderMapValue): HeaderRow[] { + if (!value || typeof value !== 'object') return []; + const out: HeaderRow[] = []; + for (const [name, raw] of Object.entries(value)) { + if (Array.isArray(raw)) { + for (const v of raw) { + out.push({ name, value: typeof v === 'string' ? v : String(v) }); + } + } else if (typeof raw === 'string') { + out.push({ name, value: raw }); + } + } + return out; +} + +function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record | Record { + if (mode === 'v1') { + const map: Record = {}; + for (const r of rows) { + if (!r.name) continue; + map[r.name] = r.value ?? ''; + } + return map; + } + const map: Record = {}; + for (const r of rows) { + if (!r.name) continue; + const list = map[r.name] ?? []; + list.push(r.value ?? ''); + map[r.name] = list; + } + return map; +} + +export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) { + const rows = useMemo(() => mapToRows(value), [value]); + + function commit(next: HeaderRow[]) { + onChange?.(rowsToMap(next, mode)); + } + + function setRow(index: number, patch: Partial) { + const next = rows.slice(); + next[index] = { ...next[index], ...patch }; + commit(next); + } + + function addRow() { + commit([...rows, { name: '', value: '' }]); + } + + function removeRow(index: number) { + const next = rows.slice(); + next.splice(index, 1); + commit(next); + } + + return ( + <> + {rows.map((row, idx) => ( + + {`${idx + 1}`} + setRow(idx, { name: e.target.value })} + /> + setRow(idx, { value: e.target.value })} + /> + + + ); +} diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index 0704cade..c6e3f8cc 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -57,6 +57,7 @@ import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt' import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; import DateTimePicker from '@/components/DateTimePicker'; +import HeaderMapEditor from '@/components/HeaderMapEditor'; import InputAddon from '@/components/InputAddon'; import JsonEditor from '@/components/JsonEditor'; import type { FormInstance } from 'antd'; @@ -1255,6 +1256,12 @@ export default function InboundFormModal({ > + + + )} @@ -1486,6 +1493,12 @@ export default function InboundFormModal({ > + + + )} diff --git a/frontend/src/pages/xray/OutboundFormModal.tsx b/frontend/src/pages/xray/OutboundFormModal.tsx index ef39ad24..e48994d5 100644 --- a/frontend/src/pages/xray/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/OutboundFormModal.tsx @@ -15,6 +15,7 @@ import { } from 'antd'; import { DeleteOutlined, MinusOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons'; +import HeaderMapEditor from '@/components/HeaderMapEditor'; import InputAddon from '@/components/InputAddon'; import JsonEditor from '@/components/JsonEditor'; import { Wireguard } from '@/utils'; @@ -1196,6 +1197,12 @@ export default function OutboundFormModal({ > + + + )} @@ -1237,6 +1244,12 @@ export default function OutboundFormModal({ > + + + )}