瀏覽代碼

refactor(frontend): extract toHeaders + toV2Headers to lib/xray/headers.ts

First Step 3d extraction. The XrayCommonClass static helpers
toHeaders/toV2Headers are pure data shape conversions with no class
hierarchy needs, so they move to a standalone module that callers can
import without dragging in models/inbound.ts. The new module exports
HeaderEntry + V2HeaderMap as named types so consumers stop reaching into
the legacy class for type shapes.

A new test file (headers.test.ts) asserts byte-equality with the legacy
XrayCommonClass.toHeaders / .toV2Headers across 18 cases — null /
undefined / primitive inputs, single-string headers, array-valued
headers, duplicate names, empty-name and empty-value filtering, both
arr=true (TCP request/response shape) and arr=false (WS / xHTTP / sockopt
shape). Drift between the legacy and new impls fails these tests, so the
follow-up call-site swap stays safe.

Callers (TcpStreamSettings, WsStreamSettings, HTTPUpgradeStreamSettings,
TunnelSettings, etc.) still go through XrayCommonClass for now — those
swaps land alongside class-method extractions in subsequent turns.

Suite is now 44 tests across 5 files; typecheck + lint clean.
MHSanaei 1 天之前
父節點
當前提交
922a442264
共有 2 個文件被更改,包括 116 次插入0 次删除
  1. 57 0
      frontend/src/lib/xray/headers.ts
  2. 59 0
      frontend/src/test/headers.test.ts

+ 57 - 0
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<string, string | string[]>;
+
+// 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<string, unknown>;
+  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;
+}

+ 59 - 0
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));
+    });
+  }
+});