diff --git a/frontend/src/lib/xray/headers.ts b/frontend/src/lib/xray/headers.ts new file mode 100644 index 00000000..32694986 --- /dev/null +++ b/frontend/src/lib/xray/headers.ts @@ -0,0 +1,57 @@ +// Pure helpers for header-shape conversion between the panel's internal +// HeaderEntry[] form and Xray's V2-style header map. Extracted from +// XrayCommonClass.toHeaders / .toV2Headers so callers can stop relying on +// the class hierarchy. Behavior is byte-equivalent to the legacy methods — +// the shadow tests in src/test/headers.test.ts pin that. + +export interface HeaderEntry { + name: string; + value: string; +} + +export type V2HeaderMap = Record; + +// Expand a V2-style header map into the panel's flat HeaderEntry[]. A +// header whose value is an array yields one entry per item, preserving +// order; a string value yields a single entry. Non-object inputs (null, +// undefined, primitives) yield []. +export function toHeaders(v2Headers: unknown): HeaderEntry[] { + const out: HeaderEntry[] = []; + if (!v2Headers || typeof v2Headers !== 'object') return out; + const map = v2Headers as Record; + for (const key of Object.keys(map)) { + const values = map[key]; + if (typeof values === 'string') { + out.push({ name: key, value: values }); + } else if (Array.isArray(values)) { + for (const v of values) { + if (typeof v === 'string') out.push({ name: key, value: v }); + } + } + } + return out; +} + +// Collapse a HeaderEntry[] back into a V2-style header map. When `arr` is +// true (the default — matches Xray's TCP/WS/HTTP request/response shape), +// duplicate header names accumulate into a string[]. When false (used for +// WS/HTTPUpgrade/xHTTP top-level headers, sockopt portMap, etc.), the +// last value wins. Entries with empty name or value are skipped — same as +// the legacy ObjectUtil.isEmpty() filter. +export function toV2Headers(headers: HeaderEntry[], arr: boolean = true): V2HeaderMap { + const out: V2HeaderMap = {}; + for (const { name, value } of headers) { + if (name == null || name === '' || value == null || value === '') continue; + if (!(name in out)) { + out[name] = arr ? [value] : value; + continue; + } + const existing = out[name]; + if (arr && Array.isArray(existing)) { + existing.push(value); + } else { + out[name] = value; + } + } + return out; +} diff --git a/frontend/src/test/headers.test.ts b/frontend/src/test/headers.test.ts new file mode 100644 index 00000000..fd029a1f --- /dev/null +++ b/frontend/src/test/headers.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { toHeaders, toV2Headers, type HeaderEntry } from '@/lib/xray/headers'; +import { XrayCommonClass } from '@/models/inbound'; + +// Shadow harness: the new pure helpers must agree byte-for-byte with the +// legacy XrayCommonClass static methods. Drift here is a regression. + +const headerMapCases: Array<[string, unknown]> = [ + ['null', null], + ['undefined', undefined], + ['primitive', 'not-an-object'], + ['empty', {}], + ['single string', { Host: 'example.test' }], + ['single array', { Host: ['a.example.test'] }], + ['multi array', { Accept: ['text/html', 'application/json'] }], + ['mixed', { Host: 'a.example.test', 'X-Trace': ['1', '2'] }], +]; + +describe('toHeaders parity with XrayCommonClass.toHeaders', () => { + for (const [label, input] of headerMapCases) { + it(label, () => { + expect(toHeaders(input)).toEqual(XrayCommonClass.toHeaders(input)); + }); + } +}); + +const entryCases: Array<[string, HeaderEntry[]]> = [ + ['empty', []], + ['single', [{ name: 'Host', value: 'example.test' }]], + ['duplicate name', [ + { name: 'Accept', value: 'text/html' }, + { name: 'Accept', value: 'application/json' }, + ]], + ['empty name skipped', [ + { name: '', value: 'ignored' }, + { name: 'X-Real', value: 'kept' }, + ]], + ['empty value skipped', [ + { name: 'X-Empty', value: '' }, + { name: 'X-Real', value: 'kept' }, + ]], +]; + +describe('toV2Headers parity (arr=true)', () => { + for (const [label, input] of entryCases) { + it(label, () => { + expect(toV2Headers(input, true)).toEqual(XrayCommonClass.toV2Headers(input, true)); + }); + } +}); + +describe('toV2Headers parity (arr=false)', () => { + for (const [label, input] of entryCases) { + it(label, () => { + expect(toV2Headers(input, false)).toEqual(XrayCommonClass.toV2Headers(input, false)); + }); + } +});