HeaderMapEditor.tsx 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. import { useEffect, useRef, useState } from 'react';
  2. import { Button, Input, Space } from 'antd';
  3. import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
  4. import InputAddon from '@/components/InputAddon';
  5. // Reusable header-map editor. Handles the two wire shapes Xray uses for
  6. // HTTP-style header maps:
  7. //
  8. // v1: { 'Content-Type': 'application/json', 'X-Custom': 'value' }
  9. // Used by WS / HTTPUpgrade / Hysteria masquerade. One value per
  10. // name.
  11. //
  12. // v2: { 'Accept': ['text/html', 'application/json'],
  13. // 'X-Forwarded': ['1.2.3.4'] }
  14. // Used by TCP HTTP camouflage request/response. Each header can
  15. // repeat (RFC 7230 §3.2.2).
  16. //
  17. // Internal state is always the flat list-of-rows shape regardless of
  18. // mode. Conversion to/from the wire shape happens at the value/onChange
  19. // boundary so consumers can bind straight to a Form.Item without any
  20. // extra transforms on their side.
  21. export type HeaderMapMode = 'v1' | 'v2';
  22. export type HeaderMapValue =
  23. | Record<string, string>
  24. | Record<string, string[]>
  25. | undefined;
  26. interface HeaderRow {
  27. name: string;
  28. value: string;
  29. }
  30. interface HeaderMapEditorProps {
  31. mode: HeaderMapMode;
  32. value?: HeaderMapValue;
  33. onChange?: (next: Record<string, string> | Record<string, string[]>) => void;
  34. }
  35. function mapToRows(value: HeaderMapValue): HeaderRow[] {
  36. if (!value || typeof value !== 'object') return [];
  37. const out: HeaderRow[] = [];
  38. for (const [name, raw] of Object.entries(value)) {
  39. if (Array.isArray(raw)) {
  40. for (const v of raw) {
  41. out.push({ name, value: typeof v === 'string' ? v : String(v) });
  42. }
  43. } else if (typeof raw === 'string') {
  44. out.push({ name, value: raw });
  45. }
  46. }
  47. return out;
  48. }
  49. function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, string> | Record<string, string[]> {
  50. if (mode === 'v1') {
  51. const map: Record<string, string> = {};
  52. for (const r of rows) {
  53. if (!r.name) continue;
  54. map[r.name] = r.value ?? '';
  55. }
  56. return map;
  57. }
  58. const map: Record<string, string[]> = {};
  59. for (const r of rows) {
  60. if (!r.name) continue;
  61. const list = map[r.name] ?? [];
  62. list.push(r.value ?? '');
  63. map[r.name] = list;
  64. }
  65. return map;
  66. }
  67. export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
  68. // Local state holds rows including blanks. Without it, addRow() would
  69. // append a {name:'', value:''} that rowsToMap immediately filters out
  70. // before reaching the form, so the new row would never reach UI. The
  71. // form-bound map only sees rows with non-empty names; blank rows live
  72. // here until the user fills them in.
  73. const [rows, setRows] = useState<HeaderRow[]>(() => mapToRows(value));
  74. const lastEmittedRef = useRef<string>(JSON.stringify(rowsToMap(rows, mode)));
  75. // Re-sync local rows when the form value changes from outside (modal
  76. // re-open with edit data, JSON tab edits, etc.) but not when it's our
  77. // own emission echoing back.
  78. useEffect(() => {
  79. const incoming = JSON.stringify(value ?? {});
  80. if (incoming === lastEmittedRef.current) return;
  81. setRows(mapToRows(value));
  82. lastEmittedRef.current = incoming;
  83. }, [value]);
  84. function commit(next: HeaderRow[]) {
  85. setRows(next);
  86. const map = rowsToMap(next, mode);
  87. lastEmittedRef.current = JSON.stringify(map);
  88. onChange?.(map);
  89. }
  90. function setRow(index: number, patch: Partial<HeaderRow>) {
  91. const next = rows.slice();
  92. next[index] = { ...next[index], ...patch };
  93. commit(next);
  94. }
  95. function addRow() {
  96. commit([...rows, { name: '', value: '' }]);
  97. }
  98. function removeRow(index: number) {
  99. const next = rows.slice();
  100. next.splice(index, 1);
  101. commit(next);
  102. }
  103. return (
  104. <>
  105. {rows.map((row, idx) => (
  106. <Space.Compact key={idx} block className="mb-8">
  107. <InputAddon>{`${idx + 1}`}</InputAddon>
  108. <Input
  109. value={row.name}
  110. placeholder="Name"
  111. onChange={(e) => setRow(idx, { name: e.target.value })}
  112. />
  113. <Input
  114. value={row.value}
  115. placeholder="Value"
  116. onChange={(e) => setRow(idx, { value: e.target.value })}
  117. />
  118. <Button icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
  119. </Space.Compact>
  120. ))}
  121. <Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
  122. Add
  123. </Button>
  124. </>
  125. );
  126. }