HeaderMapEditor.tsx 4.5 KB

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