From 205bcc939a7bb7d242a3ba8b08b0ee82536c2eb0 Mon Sep 17 00:00:00 2001 From: eries Date: Sun, 11 Oct 2026 15:59:19 +0800 Subject: [PATCH 1/5] feat(nodes): edit XHTTP link options as fields Show a vless or trojan XHTTP link's mode, path, host, headers, padding, POST pacing and download peer as fields that rewrite only their own part of the link. Check an edited link with honk's syntax validation before the source write, so a link honk would skip is refused with its reason, and refuse links over the 8192 characters node creation accepts. --- changes/feat-xhttp-extra.md | 5 + docs/pages.md | 4 + docs/pages.zh-CN.md | 4 + docs/pages.zh-TW.md | 4 + e2e/nodes.spec.ts | 55 +++++++++++ src/dae/xhttp.test.ts | 86 ++++++++++++++++ src/dae/xhttp.ts | 146 +++++++++++++++++++++++++++ src/features/nodes/Nodes.tsx | 7 +- src/features/nodes/XhttpOptions.tsx | 147 ++++++++++++++++++++++++++++ src/features/nodes/useNodesPage.ts | 28 +++++- src/features/nodes/view.ts | 9 +- src/i18n/locales/en.json | 20 ++++ src/i18n/locales/zh-CN.json | 20 ++++ src/i18n/locales/zh-TW.json | 20 ++++ 14 files changed, 548 insertions(+), 7 deletions(-) create mode 100644 changes/feat-xhttp-extra.md create mode 100644 src/dae/xhttp.test.ts create mode 100644 src/dae/xhttp.ts create mode 100644 src/features/nodes/XhttpOptions.tsx diff --git a/changes/feat-xhttp-extra.md b/changes/feat-xhttp-extra.md new file mode 100644 index 000000000..3de7068ee --- /dev/null +++ b/changes/feat-xhttp-extra.md @@ -0,0 +1,5 @@ +Added + +- The node dialog shows a vless or trojan XHTTP link's options as fields: mode, path, host, headers, padding and POST pacing from its extra JSON, and a separate download peer. Editing a field rewrites only that part of the link. +- Saving an edited node link first asks honk to read it, so a link honk would skip is refused with its reason instead of the node silently disappearing. +- Adding a node refuses a link longer than the 8192 characters the API accepts. diff --git a/docs/pages.md b/docs/pages.md index a5f7efd93..eb4eb30d4 100644 --- a/docs/pages.md +++ b/docs/pages.md @@ -34,6 +34,10 @@ Sign-in is a page of its own: one card centred on the theme background, with Lan On Policies, use the pencil button on a group card to edit its policy and included regions, subscriptions and nodes. The dialog shows the matching nodes and supports undo before saving. New group opens the same editor. Group links on Nodes return to the group card. In a node's Node actions menu, Add to group lists groups from writable main and include files; choosing one opens its editor with the node staged for confirmation. Nodes and subscriptions declared in include files cannot be removed from this page; the disabled action explains why. +### XHTTP node options + +When Paste node link or Edit… holds a vless or trojan XHTTP link, XHTTP options shows its mode, path, host, headers, padding, POST pacing and separate download peer as fields. Each field rewrites only its own part of the link, which remains what is saved. Before an edited link is written, honk reads it alone; a link honk would skip is refused with honk's reason instead of the node disappearing after reload. Adding a node takes a link of at most 8192 characters; put a longer one in the configuration editor. + ### Traffic and connections The Traffic tab of Connections plots each connection's upload against its download, coloured by outbound; select a point to open that connection. The Connections tab groups live connections by device or by outbound, filters them by protocol and outbound, and exports them as CSV. diff --git a/docs/pages.zh-CN.md b/docs/pages.zh-CN.md index 32fa1f595..2cf15f698 100644 --- a/docs/pages.zh-CN.md +++ b/docs/pages.zh-CN.md @@ -34,6 +34,10 @@ 在策略页按组卡片的铅笔按钮,即可编辑策略及选入的地区、订阅与节点。对话框显示符合条件的节点,保存前可撤销更改。新建组使用同一个编辑器。节点页的组链接会跳转到组卡片;节点操作菜单中,加入组列出可写主文件与包含文件中的组;选择后打开编辑器,暂存选中的节点,待确认后保存。在包含文件中声明的节点与订阅不能从此页移除,禁用的操作会说明原因。 +### XHTTP 节点选项 + +粘贴节点链接或编辑节点时,若链接是 vless 或 trojan 的 XHTTP 链接,XHTTP 选项会把其模式、路径、host、header、填充、POST 节奏和独立下载端显示为字段。每个字段只改写链接中对应的部分,保存的仍是链接本身。写入编辑后的链接前,会先请 honk 单独读取它;honk 会跳过的链接会连同 honk 给出的原因被拒绝,而不是在重载后节点消失。添加节点的链接最多 8192 个字符;更长的链接请在配置编辑器中写入。 + ### 流量与连接 连接页的流量标签页用散点图展示每条连接的上传量与下载量,并按出站着色;选中数据点即可打开对应的连接。连接标签页按设备或出站对实时连接分组,可按协议和出站筛选,并导出为 CSV。 diff --git a/docs/pages.zh-TW.md b/docs/pages.zh-TW.md index 13d6900d4..3a03bf2dd 100644 --- a/docs/pages.zh-TW.md +++ b/docs/pages.zh-TW.md @@ -34,6 +34,10 @@ 在策略頁按群組卡片的鉛筆按鈕,即可編輯策略及選入的地區、訂閱與節點。對話框顯示符合條件的節點,儲存前可復原變更。新增群組使用同一個編輯器。節點頁的群組連結會跳轉到群組卡片;節點操作選單中,加入群組列出可寫入主檔案與包含檔案中的群組;選擇後開啟編輯器,暫存選取的節點,待確認後儲存。在包含檔案中宣告的節點與訂閱不能從此頁移除,停用的操作會說明原因。 +### XHTTP 節點選項 + +貼上節點連結或編輯節點時,若連結是 vless 或 trojan 的 XHTTP 連結,XHTTP 選項會把其模式、路徑、host、header、填充、POST 節奏與獨立下載端顯示為欄位。每個欄位只改寫連結中對應的部分,儲存的仍是連結本身。寫入編輯後的連結前,會先請 honk 單獨讀取它;honk 會略過的連結會連同 honk 給出的原因被拒絕,而不是在重新載入後節點消失。新增節點的連結最多 8192 個字元;更長的連結請在組態編輯器中寫入。 + ### 流量與連線 連線頁的流量分頁以散佈圖呈現每條連線的上傳量與下載量,並依出站著色;選取資料點即可開啟對應的連線。連線分頁依裝置或出站將即時連線分組,可依協定與出站篩選,並匯出為 CSV。 diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts index 08011e2ab..47fa6c632 100644 --- a/e2e/nodes.spec.ts +++ b/e2e/nodes.spec.ts @@ -155,6 +155,61 @@ for (const transport of ['xhttp', 'splithttp']) expect((await api.config()).sources.find(item => item.id === source.id)!.content).toBe(source.content.replace(link, updated)); }); +test('XHTTP fields rewrite only their part of the link that is added', async ({page}) => { + const {api, handlers, requests} = await mockBackend(page); + handlers['POST nodes'] = request => api.createNode(request.postDataJSON()); + const link = 'vless://00000000-0000-4000-8000-000000000001@example.com:443?security=tls&type=xhttp&path=%2Fx%252Fy&alpn=h2#edge'; + await page.goto('/#/nodes?provider=inline'); + await page.getByRole('button', {name: 'Paste node link', exact: true}).click(); + const add = page.getByRole('dialog'); + await add.getByLabel('Name', {exact: true}).fill('edge'); + await add.getByLabel('Node link', {exact: true}).fill(link); + await add.getByRole('button', {name: 'XHTTP options'}).click(); + await expect(add.getByLabel('Path', {exact: true})).toHaveValue('/x%2Fy'); + await add.getByLabel('Padding bytes (xPaddingBytes)').fill('200-400'); + // A value typed before its name waits in the row instead of being refused. + await add.getByLabel('Header 1 value').fill('a+b'); + await add.getByLabel('Header 1 name').fill('X-Test'); + const expected = link.replace('#edge', `&extra=${encodeURIComponent(JSON.stringify({xPaddingBytes: '200-400', headers: {'X-Test': 'a+b'}}))}#edge`); + await expect(add.getByLabel('Node link', {exact: true})).toHaveValue(expected); + await add.getByRole('button', {name: 'Add', exact: true}).click(); + await expect(add).toHaveCount(0); + expect(requests.find(request => request.method() === 'POST' && new URL(request.url()).pathname === '/api/v1/nodes')!.postDataJSON()).toEqual({ + name: 'edge', + link: expected + }); +}); + +for (const [code, refused] of [ + ['invalid-config-value', true], + ['legacy-config-warning', false] +] as const) + test(`an edited link that warns ${code} is ${refused ? 'refused before any write' : 'written'}`, async ({page}) => { + const {handlers, requests} = await mockBackend(page); + const reason = 'XHTTP xPaddingBytes must stay within 1-8192'; + handlers['POST config/validate'] = async () => ({ + valid: true, + diagnostics: [{level: 'warning', source_id: 'node', line: 2, column: 3, span: null, code, message: reason}], + generation_id: 'mock:1', + validated_at: new Date().toISOString() + }); + await page.goto('/#/nodes?provider=inline'); + const row = nodeRows(page).filter({hasText: 'jp-01'}); + await row.getByRole('button', {name: 'Node actions', exact: true}).click(); + await page.getByRole('menuitem', {name: 'Edit…', exact: true}).click(); + const edit = page.getByRole('dialog', {name: 'Edit node jp-01'}); + await edit.getByRole('button', {name: 'XHTTP options'}).click(); + await edit.getByLabel('Padding bytes (xPaddingBytes)').fill('0-10'); + await edit.getByRole('button', {name: 'Apply', exact: true}).click(); + if (refused) { + await expect(edit.getByRole('alert')).toContainText(`honk cannot read this link: ${reason}`); + expect(requests.some(request => request.method() === 'PUT')).toBe(false); + } else { + await expect(edit).toHaveCount(0); + expect(requests.some(request => request.method() === 'PUT')).toBe(true); + } + }); + test('include declarations block removal even when the main source also declares them', async ({page}) => { const {api} = await mockBackend(page); const reason = 'Declared outside the main configuration file. Remove it from that file.'; diff --git a/src/dae/xhttp.test.ts b/src/dae/xhttp.test.ts new file mode 100644 index 000000000..bc4b4a589 --- /dev/null +++ b/src/dae/xhttp.test.ts @@ -0,0 +1,86 @@ +import {expect, it} from 'vitest'; +import {readXhttp, writeXhttp} from './xhttp'; + +const extra = (value: unknown) => `extra=${encodeURIComponent(JSON.stringify(value))}`; +const base = 'vless://00000000-0000-4000-8000-000000000001@example.com:443'; +const decoded = (link: string) => JSON.parse(new URLSearchParams(link.split('?')[1].split('#')[0]).get('extra') ?? '{}'); + +it.each([ + `${base}?security=tls&type=xhttp&path=%2Fx%252Fy&extra=%7B%22headers%22%3A%7B%22X-Test%22%3A%22a%2Bb%22%7D%7D#edge`, + `trojan://pw@example.com:443?type=splithttp&alpn=h2,http/1.1&fp=chrome&mode=stream-up#a%20b`, + `${base}?type=xhttp&${extra({xmux: {maxConcurrency: '1'}, xPaddingBytes: {min: 1, max: 9}})}`, + ` ${base}?type=xhttp&path=%2Fx#typed with blanks ` +])('writes nothing back when nothing changed: %s', link => { + expect(writeXhttp(link, readXhttp(link)!)).toBe(link); +}); + +it('reads query claims over extra and decodes the literal path once', () => { + const link = `${base}?type=xhttp&path=%2Fx%252Fy&${extra({path: '/ignored/', host: 'cdn.example', xPaddingBytes: {min: 5, max: 9}, noGRPCHeader: true})}`; + expect(readXhttp(link)).toMatchObject({path: '/x%2Fy', host: 'cdn.example', xPaddingBytes: '5-9', noGRPCHeader: true, mode: ''}); +}); + +it('rewrites only the changed option and keeps unknown extra members', () => { + const head = `${base}?security=tls&type=xhttp&alpn=h2,http/1.1&sni=a.example`; + const link = `${head}&${extra({xmux: {maxConcurrency: '1'}, headers: {'X-A': 'a+b'}})}#edge`; + const next = writeXhttp(link, {...readXhttp(link)!, xPaddingBytes: '200-400', scMinPostsIntervalMs: '10'}); + expect(next.startsWith(`${head}&extra=`)).toBe(true); + expect(next.endsWith('#edge')).toBe(true); + expect(decoded(next)).toEqual({xmux: {maxConcurrency: '1'}, headers: {'X-A': 'a+b'}, xPaddingBytes: '200-400', scMinPostsIntervalMs: 10}); + expect(readXhttp(next)!.headers).toEqual([['X-A', 'a+b']]); +}); + +it('moves an edited request field to the query and drops its stale extra copy', () => { + const link = `${base}?type=xhttp&${extra({path: '/old/', mode: 'packet-up'})}`; + const next = writeXhttp(link, {...readXhttp(link)!, path: '/new path/'}); + expect(next).toBe(`${base}?type=xhttp&${extra({mode: 'packet-up'})}&path=%2Fnew%20path%2F`); + const cleared = writeXhttp(next, {...readXhttp(next)!, mode: '', headers: [['', 'dropped']]}); + expect(cleared).toBe(`${base}?type=xhttp&path=%2Fnew%20path%2F`); +}); + +it('writes a download peer honk can admit and keeps its other members', () => { + const link = `${base}?type=xhttp&${extra({downloadSettings: {address: 'old.example', port: 443, network: 'xhttp', security: 'tls', alpn: ['h2'], tlsSettings: {serverName: 'sni.example', fingerprint: 'chrome'}}})}`; + const download = {address: 'down.example', port: '8443', serverName: '', path: '/down/', host: ''}; + expect(decoded(writeXhttp(link, {...readXhttp(link)!, download})).downloadSettings).toEqual({ + address: 'down.example', + port: 8443, + network: 'xhttp', + security: 'tls', + alpn: ['h2'], + tlsSettings: {fingerprint: 'chrome'}, + xhttpSettings: {path: '/down/'} + }); + expect(decoded(writeXhttp(link, {...readXhttp(link)!, download: null}))).toEqual({}); +}); + +it.each([ + `${base}?type=ws&path=%2F`, + `vmess://e30=`, + `${base}?type=xhttp&path=%2Fa&path=%2Fb`, + `${base}?type=xhttp&extra=%7Bbroken`, + `${base}?type=xhttp&extra=%5B%5D`, + `${base}?type=xhttp&path=%E0%A4%A`, + `${base}?type=xhttp&${extra({headers: {'X-A': {nested: true}}})}`, + `${base}?type=xhttp&${extra({xPaddingBytes: {min: '1', max: 2}})}`, + `${base}?type=xhttp&${extra({downloadSettings: {address: 'd.example', port: 443, tlsSettings: 'x'}})}` +])('leaves links it cannot edit field by field alone: %s', link => { + expect(readXhttp(link)).toBeNull(); + expect( + writeXhttp(link, { + mode: 'stream-one', + path: '', + host: '', + headers: [], + xPaddingBytes: '', + noGRPCHeader: false, + scMaxEachPostBytes: '', + scMinPostsIntervalMs: '', + download: null + }) + ).toBe(link); +}); + +it('writes nothing for header rows that are still waiting for a name', () => { + const link = `${base}?type=xhttp&extra=%7B%20%22headers%22%3A%20%7B%22X-A%22%3A%20%22a%22%7D%7D`; + const fields = readXhttp(link)!; + expect(writeXhttp(link, {...fields, headers: [...fields.headers, ['', 'pending']]})).toBe(link); +}); diff --git a/src/dae/xhttp.ts b/src/dae/xhttp.ts new file mode 100644 index 000000000..7a2dc3008 --- /dev/null +++ b/src/dae/xhttp.ts @@ -0,0 +1,146 @@ +// XHTTP options of a vless or trojan share link: the request fields in its query, the rest in its Xray `extra` +// JSON. The link stays the only copy: a write changes just the parameters whose shown value changed, so every other +// byte survives, and honk alone decides what is valid. + +export type XhttpDownload = {address: string; port: string; serverName: string; path: string; host: string}; +export type XhttpFields = { + mode: string; + path: string; + host: string; + headers: Array<[string, string]>; + xPaddingBytes: string; + noGRPCHeader: boolean; + scMaxEachPostBytes: string; + scMinPostsIntervalMs: string; + download: XhttpDownload | null; +}; +export const xhttpModes = ['auto', 'packet-up', 'stream-up', 'stream-one']; +const ranges = ['xPaddingBytes', 'scMaxEachPostBytes', 'scMinPostsIntervalMs'] as const; + +type Json = Record; + +const isObject = (value: unknown): value is Json => typeof value === 'object' && value !== null && !Array.isArray(value); +const decode = (raw: string) => decodeURIComponent(raw.replace(/\+/g, ' ')); +// Xray reads a bare count as a number and anything else, `min-max` included, as text. +const scalar = (value: string) => (/^\d+$/.test(value) ? Number(value) : value); + +// A value a field can show and write back unchanged. Any other shape makes the link one these fields do not edit. +function text(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'string' || typeof value === 'number') return String(value); + throw new Error('not editable'); +} +function rangeText(value: unknown): string { + return isObject(value) && typeof value.min === 'number' && typeof value.max === 'number' ? `${value.min}-${value.max}` : text(value); +} + +// Empty text, false, null and empty objects are what a field leaves unset, so they remove the member. +function put(target: Json, key: string, value: unknown) { + if (value === '' || value === false || value == null || (isObject(value) && !Object.keys(value).length)) delete target[key]; + else target[key] = value; +} + +function fields(query: Partial>, extra: Json): XhttpFields { + const {headers = {}, downloadSettings: download} = extra; + if (!isObject(headers) || (download !== undefined && !isObject(download))) throw new Error('not editable'); + const member = (group: string, name: string) => { + const value = download?.[group]; + if (value !== undefined && !isObject(value)) throw new Error('not editable'); + return text(value?.[name]); + }; + return { + mode: query.mode ?? text(extra.mode), + path: query.path ?? text(extra.path), + host: query.host ?? text(extra.host), + headers: Object.entries(headers).map(([name, value]) => { + if (typeof value !== 'string') throw new Error('not editable'); + return [name, value]; + }), + xPaddingBytes: rangeText(extra.xPaddingBytes), + noGRPCHeader: extra.noGRPCHeader === true, + scMaxEachPostBytes: rangeText(extra.scMaxEachPostBytes), + scMinPostsIntervalMs: rangeText(extra.scMinPostsIntervalMs), + download: download + ? { + address: text(download.address), + port: text(download.port), + serverName: member('tlsSettings', 'serverName'), + path: member('xhttpSettings', 'path'), + host: member('xhttpSettings', 'host') + } + : null + }; +} + +function parse(link: string) { + const match = /^((?:vless|trojan):\/\/[^?#]*)(?:\?([^#]*))?(#.*)?$/i.exec(link.trim()); + if (!match) return null; + const params = (match[2] ?? '').split('&').filter(Boolean); + try { + const query: Partial> = {}; + for (const param of params) { + const key = decode(param.split('=', 1)[0]); + if (!['type', 'network', 'mode', 'path', 'host', 'extra'].includes(key)) continue; + // A repeated claim is honk's to reconcile; structured editing would have to pick one. + if (key in query) return null; + query[key] = decode(param.includes('=') ? param.slice(param.indexOf('=') + 1) : ''); + } + const transport = query.type ?? query.network; + if (transport !== 'xhttp' && transport !== 'splithttp') return null; + const extra: unknown = query.extra === undefined ? {} : JSON.parse(query.extra); + if (!isObject(extra)) return null; + return {head: match[1], params, fragment: match[3] ?? '', extra, current: fields(query, extra)}; + } catch { + return null; + } +} + +// The structured view of an XHTTP link, or null for any other link or one too irregular to edit field by field. +export function readXhttp(link: string): XhttpFields | null { + return parse(link)?.current ?? null; +} + +export function writeXhttp(link: string, next: XhttpFields): string { + const parsed = parse(link); + if (!parsed) return link; + const {current} = parsed; + const params = [...parsed.params]; + const extra = structuredClone(parsed.extra); + const setParam = (key: string, value: string) => { + const at = params.findIndex(param => decode(param.split('=', 1)[0]) === key); + const param = `${key}=${encodeURIComponent(value)}`; + if (!value) { + if (at >= 0) params.splice(at, 1); + } else if (at >= 0) params[at] = param; + else params.push(param); + }; + for (const key of ['mode', 'path', 'host'] as const) { + if (next[key] === current[key]) continue; + setParam(key, next[key]); + // The query claim wins; a stale copy in extra would only resurface once the query claim is cleared. + delete extra[key]; + } + for (const key of ranges) if (next[key] !== current[key]) put(extra, key, scalar(next[key].trim())); + if (next.noGRPCHeader !== current.noGRPCHeader) put(extra, 'noGRPCHeader', next.noGRPCHeader); + // Only named headers can live in the link; rows still waiting for a name change nothing. + if (JSON.stringify(next.headers) !== JSON.stringify(current.headers)) put(extra, 'headers', Object.fromEntries(next.headers.filter(([name]) => name.trim()))); + if (!next.download) put(extra, 'downloadSettings', null); + else if (JSON.stringify(next.download) !== JSON.stringify(current.download)) { + const old = isObject(extra.downloadSettings) ? extra.downloadSettings : {}; + const settings: Json = {...old}; + const tls = isObject(old.tlsSettings) ? {...old.tlsSettings} : {}; + const xhttp = isObject(old.xhttpSettings) ? {...old.xhttpSettings} : {}; + put(tls, 'serverName', next.download.serverName); + put(xhttp, 'path', next.download.path); + put(xhttp, 'host', next.download.host); + // honk accepts only an H2-over-TLS XHTTP download peer, so those two members are not choices. + Object.assign(settings, {address: next.download.address, port: scalar(next.download.port), network: 'xhttp', security: 'tls'}); + put(settings, 'tlsSettings', tls); + put(settings, 'xhttpSettings', xhttp); + extra.downloadSettings = settings; + } + if (JSON.stringify(extra) !== JSON.stringify(parsed.extra)) setParam('extra', Object.keys(extra).length ? JSON.stringify(extra) : ''); + const written = `${parsed.head}${params.length ? `?${params.join('&')}` : ''}${parsed.fragment}`; + // Unchanged means the very same string, surrounding blanks included, so a caller can compare links directly. + return written === link.trim() ? link : written; +} diff --git a/src/features/nodes/Nodes.tsx b/src/features/nodes/Nodes.tsx index bbe548bc7..23c410f7c 100644 --- a/src/features/nodes/Nodes.tsx +++ b/src/features/nodes/Nodes.tsx @@ -1,4 +1,4 @@ -import type {FormEvent} from 'react'; +import {lazy, Suspense, type FormEvent} from 'react'; import {useT} from '../../i18n'; import { Button, @@ -22,6 +22,8 @@ import {ProviderCards} from './ProviderCards'; import {NodeTable} from './NodeTable'; import {useNodesPage} from './useNodesPage'; import {SubscriptionFields} from './SubscriptionFields'; +// Only a node dialog holding an XHTTP link uses these fields, so they load with the first node dialog. +const XhttpOptions = lazy(() => import('./XhttpOptions').then(module => ({default: module.XhttpOptions}))); const dialogForm = 'nodes-dialog-form'; export function Nodes(props: PageProps) { const t = useT(); @@ -188,6 +190,9 @@ export function Nodes(props: PageProps) { placeholder="vless://…" onChange={value => setForm({...form, value})} /> + + setForm({...form, value})} /> + )} diff --git a/src/features/nodes/XhttpOptions.tsx b/src/features/nodes/XhttpOptions.tsx new file mode 100644 index 000000000..4e8b0746c --- /dev/null +++ b/src/features/nodes/XhttpOptions.tsx @@ -0,0 +1,147 @@ +import {useState} from 'react'; +import {useT} from '../../i18n'; +import {Disclosure, LabeledSelect, Switch, TextField, Toolbar} from '../../ui/ui'; +import {readXhttp, writeXhttp, xhttpModes, type XhttpDownload, type XhttpFields} from '../../dae/xhttp'; + +// The XHTTP options of the link being typed. Each change rewrites the link, which remains what is saved and what +// honk validates; a link these fields cannot represent shows none of them. +export function XhttpOptions({link, onChange, isDisabled}: {link: string; onChange: (link: string) => void; isDisabled?: boolean}) { + const t = useT(); + const fields = readXhttp(link); + // A header without a name cannot live in the link yet, so typed rows stay here until they have one. Rows the link + // would not write back unchanged were edited elsewhere, and the link's own headers replace them. + const [rows, setRows] = useState>(fields?.headers ?? []); + if (fields && writeXhttp(link, {...fields, headers: rows}) !== link) setRows(fields.headers); + if (!fields) return null; + const set = (patch: Partial) => onChange(writeXhttp(link, {...fields, ...patch})); + const setDownload = (patch: Partial) => { + if (fields.download) set({download: {...fields.download, ...patch}}); + }; + const mode = fields.mode || 'auto'; + const setHeader = (index: number, name: string, value: string) => { + const next = [...rows]; + next[index] = [name, value]; + const kept = next.filter(([rowName, rowValue]) => rowName || rowValue); + setRows(kept); + set({headers: kept}); + }; + return ( + + {t('nodes.xhttp.help')} + ({id, label: id}))} + value={mode} + isDisabled={isDisabled} + onChange={next => next !== mode && set({mode: next})} + /> + + set({path})} + /> + set({host})} /> + + {[...rows, ['', ''] as [string, string]].map(([name, value], index) => ( + + setHeader(index, next, value)} + /> + setHeader(index, name, next)} + /> + + ))} + + set({xPaddingBytes})} + /> + set({scMaxEachPostBytes})} + /> + set({scMinPostsIntervalMs})} + /> + + set({noGRPCHeader})}> + {t('nodes.xhttp.noGrpcHeader')} + + set({download: on ? {address: '', port: '443', serverName: '', path: '', host: ''} : null})} + > + {t('nodes.xhttp.download')} + + {fields.download && ( + <> + + setDownload({address})} + /> + setDownload({port})} + /> + + setDownload({serverName})} + /> + + setDownload({path})} + /> + setDownload({host})} + /> + + + )} + + ); +} diff --git a/src/features/nodes/useNodesPage.ts b/src/features/nodes/useNodesPage.ts index 6e63542a7..8701fc9cc 100644 --- a/src/features/nodes/useNodesPage.ts +++ b/src/features/nodes/useNodesPage.ts @@ -2,11 +2,12 @@ import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {useT, useLang, formatList} from '../../i18n'; import {useCapabilities, useNodeManage, useNodes, useOutboundNames, useSteadyNodes, useProviderRefresh, useProviders, useVersion} from '../../store'; import {useCompleteness, useConfig} from '../../store/config'; -import type {ConfigSource, Node, Provider} from '../../api/model'; +import type {Capabilities, ConfigSource, Node, Provider} from '../../api/model'; import {toast, toastFailure, type Problem} from '../../ui/ui'; import {editProblem, useMainSourceEdit} from '../../store/mainSource'; -import {isWritableName, groupsNamingTag, readGroupEntries} from '../../dae/groups'; -import {isBareName} from '../../dae/text'; +import {isWritableName, groupsNamingTag, quoteName, readGroupEntries} from '../../dae/groups'; +import {isBareName, quote} from '../../dae/text'; +import {getApi} from '../../api'; import {agentProblem, readSubscriptionEntries, type SubscriptionText} from '../../dae/subscriptions'; import {engineOf} from '../../api/engines'; import {readNodeEntries, writeNodeEntry, type NodeEntry} from '../../dae/nodes'; @@ -55,6 +56,17 @@ type NodeDialog = | {kind: 'removeNode'; item: Node} | SubscriptionEdit; +// honk's parser skips a node entry it cannot read with only a warning, so writing an edited link into a whole source +// would drop the node and still reload successfully. Checking the declaration alone first returns honk's reason; null +// when it reads, or when the engine offers no syntax validation, which leaves the write to report what it can. +async function linkRejection(resources: Capabilities['resources'] | undefined, name: string, link: string) { + if (!offered(resources, 'config_validate', {whileLoading: false}) || !resources?.config_validate.modes?.includes('syntax')) return null; + const content = `node {\n ${quoteName(name)}: ${quote(link)}\n}\n`; + const {diagnostics} = await getApi().validateConfig({mode: 'syntax', sources: [{id: 'node', content}]}); + // honk documents that an accepted link only warns with legacy-* codes (node reference, `node {}` declarations). + return diagnostics.find(row => row.level === 'error' || (row.level === 'warning' && !row.code.startsWith('legacy-')))?.message ?? null; +} + export function runSubscriptionAction(query: string, action: {run: () => void} | null) { if (!action) return; replaceRoute('nodes', within(query, {editSubscription: null, editSubscriptionTag: null, focus: null})); @@ -297,7 +309,13 @@ export function useNodesPage({go, query}: PageProps) { refuse(t('nodes.editNodeMissing')); return; } - const result = await apply(text => writeNodeEntry(text, dialog.entry, {name: form.name.trim(), link: form.value.trim()}), current); + const link = form.value.trim(); + const rejected = link === dialog.entry.link ? null : await linkRejection(resources, form.name.trim(), link); + if (rejected) { + refuse(t('nodes.linkRejected', {reason: rejected})); + return; + } + const result = await apply(text => writeNodeEntry(text, dialog.entry, {name: form.name.trim(), link}), current); const problem = editProblem(result, t); if (problem) refuseNotice(problem); if (result.kind !== 'ok') return; @@ -376,7 +394,7 @@ export function useNodesPage({go, query}: PageProps) { : dialog?.kind === 'provider' ? isBareName(form.name.trim()) && !!form.value.trim() && subscriptionUrlError(form.value, t) === null && !agentError && intervalSeconds !== null : dialog?.kind === 'node' - ? form.name.trim() !== '' && !!form.value.trim() && nodeLinkError(form.value, t) === null + ? form.name.trim() !== '' && !!form.value.trim() && nodeFormReason('node', form.name, form.value, t) === null : true; const subscription: SubscriptionDraft = {name: form.name, url: form.value, interval: form.interval, agent: form.agent, cache: form.cache, route: form.route}; // A new subscription shows each option the backend lists, with its default preselected or as the placeholder; an diff --git a/src/features/nodes/view.ts b/src/features/nodes/view.ts index f45a17dc5..cfd35ca87 100644 --- a/src/features/nodes/view.ts +++ b/src/features/nodes/view.ts @@ -340,13 +340,20 @@ export function nodeLinkError(value: string, t: Translator) { return !link ? null : !isNodeLink(link) ? t('nodes.linkInvalid') : !isQuotable(link) ? t('config.unquotable') : null; } +// POST /nodes takes at most this many characters; a longer declaration still fits a configuration source edit. +const MAX_CREATE_LINK = 8192; + // Why the add dialog's submit is disabled, first applicable. Null while every required field is still empty, which // speaks for itself, and for a problem its own field already shows (the User-Agent, a group name). export function nodeFormReason(kind: string | undefined, name: string, value: string, t: Translator): string | null { const bare = name.trim(); if ((kind !== 'provider' && kind !== 'node') || (!bare && !value.trim())) return null; if (!bare) return t('nodes.nameMissing'); - if (kind === 'node') return nodeLinkError(value, t) ?? (value.trim() ? null : t('nodes.linkInvalid')); + if (kind === 'node') { + const link = value.trim(); + if (!link) return t('nodes.linkInvalid'); + return nodeLinkError(value, t) ?? ([...link].length > MAX_CREATE_LINK ? t('nodes.linkTooLong', {n: MAX_CREATE_LINK}) : null); + } if (!isBareName(bare)) return t('nodes.nameInvalid'); return subscriptionUrlError(value, t) ?? (value.trim() ? null : t('nodes.urlInvalid')); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b03f3abd6..57165d1d8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1043,6 +1043,8 @@ "nodes.latency.unmeasuredList": "{n} not measured: {names}", "nodes.link": "Node link", "nodes.linkInvalid": "The node link must be a protocol://… link, such as ss://, vmess://, vless://, trojan:// or hysteria2://", + "nodes.linkRejected": "honk cannot read this link: {reason}", + "nodes.linkTooLong": "Adding a node takes a link of at most {n} characters; put a longer one in the configuration editor", "nodes.manualOnly": "Manual only", "nodes.minutes": "Minutes", "nodes.moreGroups": { @@ -1105,6 +1107,24 @@ "nodes.urlScheme": "Without a scheme, https:// is added.", "nodes.usage": "Usage", "nodes.viewNodes": "View nodes", + "nodes.xhttp.download": "Separate download peer", + "nodes.xhttp.downloadAddress": "Download address", + "nodes.xhttp.downloadHelp": "Xray downloadSettings: GET requests go to this TLS H2 endpoint and inherit nothing from the upload side.", + "nodes.xhttp.downloadHost": "Download HTTP host", + "nodes.xhttp.downloadPath": "Download path", + "nodes.xhttp.downloadPort": "Port", + "nodes.xhttp.downloadServerName": "Download TLS server name", + "nodes.xhttp.headerName": "Header {n} name", + "nodes.xhttp.headerValue": "Header {n} value", + "nodes.xhttp.help": "These fields edit the link's XHTTP parameters and its extra JSON; empty fields use the defaults shown. honk checks the link when it is saved, and a changed option gives the node a new ID.", + "nodes.xhttp.host": "HTTP host", + "nodes.xhttp.mode": "Mode", + "nodes.xhttp.noGrpcHeader": "Omit the gRPC content type (noGRPCHeader)", + "nodes.xhttp.padding": "Padding bytes (xPaddingBytes)", + "nodes.xhttp.path": "Path", + "nodes.xhttp.postBytes": "Max bytes per POST (scMaxEachPostBytes)", + "nodes.xhttp.postInterval": "Min POST interval ms (scMinPostsIntervalMs)", + "nodes.xhttp.title": "XHTTP options", "ov.attachments": "Attachments", "ov.available": "Available", "ov.cgroupHelp.service": "This cgroup holds only this service. Its used memory counts toward this service, and usage is the used memory against the limit.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 65a486475..e7cfb688c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -896,6 +896,8 @@ "nodes.latency.unmeasuredList": "尚未测量({n} 个):{names}", "nodes.link": "节点链接", "nodes.linkInvalid": "节点链接须为 协议://… 格式,例如 ss://、vmess://、vless://、trojan://、hysteria2://", + "nodes.linkRejected": "honk 无法读取此链接:{reason}", + "nodes.linkTooLong": "添加节点的链接最多 {n} 个字符;更长的链接请在配置编辑器中写入", "nodes.manualOnly": "仅手动", "nodes.minutes": "分钟", "nodes.moreGroups": "另有 {n} 个组", @@ -952,6 +954,24 @@ "nodes.urlScheme": "未填写协议时自动补上 https://。", "nodes.usage": "用量", "nodes.viewNodes": "查看节点", + "nodes.xhttp.download": "独立下载端", + "nodes.xhttp.downloadAddress": "下载地址", + "nodes.xhttp.downloadHelp": "Xray downloadSettings:GET 请求发往这个 TLS H2 端点,不继承上传端的任何设置。", + "nodes.xhttp.downloadHost": "下载 HTTP host", + "nodes.xhttp.downloadPath": "下载路径", + "nodes.xhttp.downloadPort": "端口", + "nodes.xhttp.downloadServerName": "下载 TLS 服务器名", + "nodes.xhttp.headerName": "Header {n} 名称", + "nodes.xhttp.headerValue": "Header {n} 值", + "nodes.xhttp.help": "这些字段编辑链接中的 XHTTP 参数及其 extra JSON;留空即使用显示的默认值。保存时由 honk 校验链接,修改选项会使节点获得新的 ID。", + "nodes.xhttp.host": "HTTP host", + "nodes.xhttp.mode": "模式", + "nodes.xhttp.noGrpcHeader": "省略 gRPC 内容类型(noGRPCHeader)", + "nodes.xhttp.padding": "填充字节(xPaddingBytes)", + "nodes.xhttp.path": "路径", + "nodes.xhttp.postBytes": "单次 POST 最大字节(scMaxEachPostBytes)", + "nodes.xhttp.postInterval": "POST 最小间隔毫秒(scMinPostsIntervalMs)", + "nodes.xhttp.title": "XHTTP 选项", "ov.attachments": "挂载点", "ov.available": "可用", "ov.cgroupHelp.service": "此 cgroup 只含本服务;已用内存计入本服务,使用率按已用量与上限计算。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 28df2c98d..91c1161ea 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -896,6 +896,8 @@ "nodes.latency.unmeasuredList": "尚未測量({n} 個):{names}", "nodes.link": "節點連結", "nodes.linkInvalid": "節點連結須為 協定://… 格式,例如 ss://、vmess://、vless://、trojan://、hysteria2://", + "nodes.linkRejected": "honk 無法讀取此連結:{reason}", + "nodes.linkTooLong": "新增節點的連結最多 {n} 個字元;更長的連結請在組態編輯器中寫入", "nodes.manualOnly": "僅手動", "nodes.minutes": "分鐘", "nodes.moreGroups": "另有 {n} 個群組", @@ -952,6 +954,24 @@ "nodes.urlScheme": "未填寫協定時會自動補上 https://。", "nodes.usage": "用量", "nodes.viewNodes": "檢視節點", + "nodes.xhttp.download": "獨立下載端", + "nodes.xhttp.downloadAddress": "下載位址", + "nodes.xhttp.downloadHelp": "Xray downloadSettings:GET 請求送往這個 TLS H2 端點,不繼承上傳端的任何設定。", + "nodes.xhttp.downloadHost": "下載 HTTP host", + "nodes.xhttp.downloadPath": "下載路徑", + "nodes.xhttp.downloadPort": "連接埠", + "nodes.xhttp.downloadServerName": "下載 TLS 伺服器名稱", + "nodes.xhttp.headerName": "Header {n} 名稱", + "nodes.xhttp.headerValue": "Header {n} 值", + "nodes.xhttp.help": "這些欄位編輯連結中的 XHTTP 參數及其 extra JSON;留空即使用顯示的預設值。儲存時由 honk 驗證連結,變更選項會讓節點取得新的 ID。", + "nodes.xhttp.host": "HTTP host", + "nodes.xhttp.mode": "模式", + "nodes.xhttp.noGrpcHeader": "省略 gRPC 內容類型(noGRPCHeader)", + "nodes.xhttp.padding": "填充位元組(xPaddingBytes)", + "nodes.xhttp.path": "路徑", + "nodes.xhttp.postBytes": "單次 POST 最大位元組(scMaxEachPostBytes)", + "nodes.xhttp.postInterval": "POST 最小間隔毫秒(scMinPostsIntervalMs)", + "nodes.xhttp.title": "XHTTP 選項", "ov.attachments": "掛載點", "ov.available": "可用", "ov.cgroupHelp.service": "此 cgroup 只含本服務;已用記憶體計入本服務,使用率依已用量與上限計算。", From 955341ebb2bff9c87fa14617f488540da8413450 Mon Sep 17 00:00:00 2001 From: eries Date: Sun, 11 Oct 2026 16:18:01 +0800 Subject: [PATCH 2/5] chore(size): raise the startup budgets for the XHTTP node strings The startup paths carry the largest language catalogue, and the XHTTP option labels and help add to all three. CI measured 238517 and 342247 bytes against the previous 238300 and 341850 limits. --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cc2485450..e03a55915 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "check": "pnpm typecheck && pnpm lint && pnpm check:i18n && pnpm format:check && pnpm test && pnpm check:gen" }, "sizeBudget": { - "startupLogin": 238300, - "startupActivity": 342050, + "startupLogin": 238700, + "startupActivity": 342450, "startupCss": 30500, "locale": 38000, "fontCss": 36000, From b016035f631884942340fc54711ce7e526efd5be Mon Sep 17 00:00:00 2001 From: eries Date: Sun, 11 Oct 2026 18:18:48 +0800 Subject: [PATCH 3/5] fix(nodes): quote a node named mux where its declaration is written honk reads a bare `mux:` line in a node section as the removed standalone mux setting and rejects it, so the edit check refused a valid link for a node named mux and renaming a node to mux broke the source. Write and check that one name quoted; group and DNS references stay unchanged. --- changes/feat-xhttp-extra.md | 1 + src/dae/nodes.test.ts | 7 +++++++ src/dae/nodes.ts | 5 ++++- src/features/nodes/useNodesPage.ts | 6 +++--- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/changes/feat-xhttp-extra.md b/changes/feat-xhttp-extra.md index 3de7068ee..fdf32a04c 100644 --- a/changes/feat-xhttp-extra.md +++ b/changes/feat-xhttp-extra.md @@ -3,3 +3,4 @@ Added - The node dialog shows a vless or trojan XHTTP link's options as fields: mode, path, host, headers, padding and POST pacing from its extra JSON, and a separate download peer. Editing a field rewrites only that part of the link. - Saving an edited node link first asks honk to read it, so a link honk would skip is refused with its reason instead of the node silently disappearing. - Adding a node refuses a link longer than the 8192 characters the API accepts. +- Renaming a node to `mux` keeps its name quoted, since honk rejects a bare `mux:` line as the removed standalone setting. diff --git a/src/dae/nodes.test.ts b/src/dae/nodes.test.ts index 0e0ed1830..d974eb0d0 100644 --- a/src/dae/nodes.test.ts +++ b/src/dae/nodes.test.ts @@ -20,6 +20,13 @@ it('refuses stale, ambiguous and structurally unsafe edits', () => { expect(readNodeEntries("node {\n 'socks5://127.0.0.1:1080'\n wrapper {\n a: 'socks5://127.0.0.1:1080'\n }\n}\n")).toEqual([]); }); +it('quotes a node renamed to mux, which honk would read bare as the removed standalone setting', () => { + const [entry] = readNodeEntries(text); + expect(writeNodeEntry(text, entry, {name: 'mux', link: entry.link})).toBe( + text.replace('"old"', "'mux'").replace("name('old',", 'name(mux,').replace('!name(old)', '!name(mux)') + ); +}); + it.each(['default', 'final'])('renames exact %s references and detects cross-source blockers', field => { const group = `group {\r\n proxy {\r\n ${field}: 'old' # keep\r\n policy: fixed(0)\r\n }\r\n}\r\n`; const input = text + group; diff --git a/src/dae/nodes.ts b/src/dae/nodes.ts index 5ca1fa2a6..c4711f073 100644 --- a/src/dae/nodes.ts +++ b/src/dae/nodes.ts @@ -4,6 +4,9 @@ import {quoteName} from './groups'; export type NodeEntry = {name: string; link: string; from: number; to: number; line: number; nameTo: number; linkFrom: number}; export const isNodeLink = (value: string) => /^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(value.trim()); +// A node declaration's key as written: honk reads a bare `mux:` line as the removed standalone mux setting, so that +// one name keeps its quotes. +export const nodeKey = (name: string) => (name === 'mux' ? quote(name) : quoteName(name)); // Only named scalar entries have an unambiguous identity without decoding a protocol's share link. export function readNodeEntries(text: string): NodeEntry[] { @@ -71,7 +74,7 @@ export function writeNodeEntry(text: string, original: NodeEntry, next: {name: s if (next.name !== original.name && readNodeEntries(text).some(entry => entry.name === next.name)) throw new LocalError('nodes.nameTaken'); const entry = entries[0]; const changes = []; - if (next.name !== original.name) changes.push({from: entry.from, to: entry.nameTo, text: quoteName(next.name)}); + if (next.name !== original.name) changes.push({from: entry.from, to: entry.nameTo, text: nodeKey(next.name)}); if (next.link !== original.link) changes.push({from: entry.linkFrom, to: entry.to, text: quote(next.link)}); if (next.name !== original.name) changes.push(...nameReferences(text, original.name).map(ref => ({...ref, text: quoteName(next.name)}))); return changes.sort((a, b) => b.from - a.from).reduce((out, change) => out.slice(0, change.from) + change.text + out.slice(change.to), text); diff --git a/src/features/nodes/useNodesPage.ts b/src/features/nodes/useNodesPage.ts index 8701fc9cc..4c7424aea 100644 --- a/src/features/nodes/useNodesPage.ts +++ b/src/features/nodes/useNodesPage.ts @@ -5,12 +5,12 @@ import {useCompleteness, useConfig} from '../../store/config'; import type {Capabilities, ConfigSource, Node, Provider} from '../../api/model'; import {toast, toastFailure, type Problem} from '../../ui/ui'; import {editProblem, useMainSourceEdit} from '../../store/mainSource'; -import {isWritableName, groupsNamingTag, quoteName, readGroupEntries} from '../../dae/groups'; +import {isWritableName, groupsNamingTag, readGroupEntries} from '../../dae/groups'; import {isBareName, quote} from '../../dae/text'; import {getApi} from '../../api'; import {agentProblem, readSubscriptionEntries, type SubscriptionText} from '../../dae/subscriptions'; import {engineOf} from '../../api/engines'; -import {readNodeEntries, writeNodeEntry, type NodeEntry} from '../../dae/nodes'; +import {nodeKey, readNodeEntries, writeNodeEntry, type NodeEntry} from '../../dae/nodes'; import type {PageProps} from '../../shell/routes'; import {replaceRoute} from '../../shell/route'; import { @@ -61,7 +61,7 @@ type NodeDialog = // when it reads, or when the engine offers no syntax validation, which leaves the write to report what it can. async function linkRejection(resources: Capabilities['resources'] | undefined, name: string, link: string) { if (!offered(resources, 'config_validate', {whileLoading: false}) || !resources?.config_validate.modes?.includes('syntax')) return null; - const content = `node {\n ${quoteName(name)}: ${quote(link)}\n}\n`; + const content = `node {\n ${nodeKey(name)}: ${quote(link)}\n}\n`; const {diagnostics} = await getApi().validateConfig({mode: 'syntax', sources: [{id: 'node', content}]}); // honk documents that an accepted link only warns with legacy-* codes (node reference, `node {}` declarations). return diagnostics.find(row => row.level === 'error' || (row.level === 'warning' && !row.code.startsWith('legacy-')))?.message ?? null; From 06cba6a2d3bbd8eef79630efa9670eb75596acec Mon Sep 17 00:00:00 2001 From: eries Date: Sun, 11 Oct 2026 23:46:28 +0800 Subject: [PATCH 4/5] fix(nodes): simplify XHTTP drafts and shared saves Keep pending headers in the node draft and run link preflight inside the shared source-save transaction. Apply only changed XHTTP fields, preserve empty headers, encode apostrophes, and explain missing edit fields. Quote generated node declaration keys uniformly and lazy-load dialog-only subscription fields without increasing size budgets. Update regressions and user documentation. --- changes/feat-xhttp-extra.md | 10 +- docs/pages.md | 4 +- docs/pages.zh-CN.md | 4 +- docs/pages.zh-TW.md | 4 +- e2e/nodes.spec.ts | 192 +++++++++++++++++++++++++++- src/dae/nodes.test.ts | 7 + src/dae/nodes.ts | 5 +- src/dae/xhttp.test.ts | 105 +++++++++++---- src/dae/xhttp.ts | 100 ++++++++++----- src/features/nodes/Nodes.tsx | 42 +++--- src/features/nodes/XhttpOptions.tsx | 38 +++--- src/features/nodes/useNodesPage.ts | 73 +++++++---- src/features/nodes/view.test.ts | 32 +++-- src/features/nodes/view.ts | 10 +- src/i18n/locales/en.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/store/config.test.ts | 131 ++++++++++++++++++- src/store/config.ts | 10 +- src/store/mainSource.ts | 6 +- 20 files changed, 621 insertions(+), 155 deletions(-) diff --git a/changes/feat-xhttp-extra.md b/changes/feat-xhttp-extra.md index fdf32a04c..106b61a02 100644 --- a/changes/feat-xhttp-extra.md +++ b/changes/feat-xhttp-extra.md @@ -1,6 +1,8 @@ Added -- The node dialog shows a vless or trojan XHTTP link's options as fields: mode, path, host, headers, padding and POST pacing from its extra JSON, and a separate download peer. Editing a field rewrites only that part of the link. -- Saving an edited node link first asks honk to read it, so a link honk would skip is refused with its reason instead of the node silently disappearing. -- Adding a node refuses a link longer than the 8192 characters the API accepts. -- Renaming a node to `mux` keeps its name quoted, since honk rejects a bare `mux:` line as the removed standalone setting. +- The node dialog shows a vless or trojan XHTTP link's options as fields: mode, path, host, headers, padding and POST pacing from its extra JSON, and a separate download peer. Editing fields changes their corresponding query parameters or JSON members; extra JSON is reserialized with JavaScript number precision. +- Header names are trimmed, and case-insensitive duplicates show a field error and block saving until corrected, keeping both editable values. Unnamed rows remain protected unsaved drafts without rewriting an empty headers object; replacing the raw link replaces those rows. +- Saving an edited node link first asks honk to read it within the same shared save transaction, so other group and routing-mode edits wait throughout validation and saving, even if the dialog closes. A link honk would skip is refused with a translated diagnostic and any backend detail, even when the backend message is empty. +- Adding a node refuses a link longer than the 8192 characters the API accepts; editing an existing declaration does not apply that creation limit, and clearing required edit fields explains why saving is disabled. +- Changed XHTTP parameters encode single quotes so header values and paths remain writable in configuration text; partial download changes preserve untouched settings. +- Renamed node declarations always quote their names, including `mux`, which honk otherwise reads as a removed standalone setting. diff --git a/docs/pages.md b/docs/pages.md index eb4eb30d4..d1ae13ddf 100644 --- a/docs/pages.md +++ b/docs/pages.md @@ -36,7 +36,9 @@ On Policies, use the pencil button on a group card to edit its policy and includ ### XHTTP node options -When Paste node link or Edit… holds a vless or trojan XHTTP link, XHTTP options shows its mode, path, host, headers, padding, POST pacing and separate download peer as fields. Each field rewrites only its own part of the link, which remains what is saved. Before an edited link is written, honk reads it alone; a link honk would skip is refused with honk's reason instead of the node disappearing after reload. Adding a node takes a link of at most 8192 characters; put a longer one in the configuration editor. +When Paste node link or Edit… holds a vless or trojan XHTTP link, XHTTP options shows its mode, path, host, headers, padding, POST pacing and separate download peer as fields. The link remains what is saved. Fields change their corresponding query parameters or extra JSON members; unchanged query parameter strings survive, but changing extra reserializes that object, normalizing its whitespace and using JavaScript's number precision. Header names are trimmed, and duplicate names (case-insensitive) must be corrected before saving. Before an edited link is written, honk reads it alone; a link honk would skip is refused with a translated diagnostic and any backend detail instead of the node disappearing after reload. The 8192-character limit applies only to adding a node; longer links can be edited in existing declarations or entered in the configuration editor. + +Unfinished header rows are protected as unsaved changes; replacing the raw link replaces those rows. Link checking and saving share one transaction, so group and routing-mode edits wait even if the node dialog closes. ### Traffic and connections diff --git a/docs/pages.zh-CN.md b/docs/pages.zh-CN.md index 2cf15f698..e5b139bcb 100644 --- a/docs/pages.zh-CN.md +++ b/docs/pages.zh-CN.md @@ -36,7 +36,9 @@ ### XHTTP 节点选项 -粘贴节点链接或编辑节点时,若链接是 vless 或 trojan 的 XHTTP 链接,XHTTP 选项会把其模式、路径、host、header、填充、POST 节奏和独立下载端显示为字段。每个字段只改写链接中对应的部分,保存的仍是链接本身。写入编辑后的链接前,会先请 honk 单独读取它;honk 会跳过的链接会连同 honk 给出的原因被拒绝,而不是在重载后节点消失。添加节点的链接最多 8192 个字符;更长的链接请在配置编辑器中写入。 +粘贴节点链接或编辑节点时,若链接是 vless 或 trojan 的 XHTTP 链接,XHTTP 选项会把其模式、路径、host、header、填充、POST 节奏和独立下载端显示为字段,保存的仍是链接本身。字段改写对应的查询参数或 extra JSON 成员;未修改的查询参数字符串会保留,但修改 extra 会重新序列化该对象,规范化其空白,并使用 JavaScript 的数字精度。Header 名称会去除首尾空白,重复名称(不区分大小写)须修正后才能保存。写入编辑后的链接前,会先请 honk 单独读取它;honk 会跳过的链接会显示翻译后的诊断及后端详细原因并被拒绝,而不是在重载后节点消失。8192 字符限制仅适用于添加节点;更长的链接可在现有节点声明中编辑,或在配置编辑器中写入。 + +尚未填写名称的 header 行也受未保存更改保护;替换原始链接会替换这些行。链接检查与保存属于同一事务,即使关闭节点对话框,组和路由模式的编辑仍须等待它完成。 ### 流量与连接 diff --git a/docs/pages.zh-TW.md b/docs/pages.zh-TW.md index 3a03bf2dd..7ff9b48cf 100644 --- a/docs/pages.zh-TW.md +++ b/docs/pages.zh-TW.md @@ -36,7 +36,9 @@ ### XHTTP 節點選項 -貼上節點連結或編輯節點時,若連結是 vless 或 trojan 的 XHTTP 連結,XHTTP 選項會把其模式、路徑、host、header、填充、POST 節奏與獨立下載端顯示為欄位。每個欄位只改寫連結中對應的部分,儲存的仍是連結本身。寫入編輯後的連結前,會先請 honk 單獨讀取它;honk 會略過的連結會連同 honk 給出的原因被拒絕,而不是在重新載入後節點消失。新增節點的連結最多 8192 個字元;更長的連結請在組態編輯器中寫入。 +貼上節點連結或編輯節點時,若連結是 vless 或 trojan 的 XHTTP 連結,XHTTP 選項會把其模式、路徑、host、header、填充、POST 節奏與獨立下載端顯示為欄位,儲存的仍是連結本身。欄位改寫對應的查詢參數或 extra JSON 成員;未修改的查詢參數字串會保留,但修改 extra 會重新序列化該物件,正規化其空白,並使用 JavaScript 的數字精度。Header 名稱會去除首尾空白,重複名稱(不區分大小寫)須修正後才能儲存。寫入編輯後的連結前,會先請 honk 單獨讀取它;honk 會略過的連結會顯示翻譯後的診斷及後端詳細原因並被拒絕,而不是在重新載入後節點消失。8192 字元限制僅適用於新增節點;更長的連結可在現有節點宣告中編輯,或在組態編輯器中寫入。 + +尚未填寫名稱的 header 行也受未儲存變更保護;替換原始連結會替換這些行。連結檢查與儲存屬於同一交易,即使關閉節點對話框,群組和路由模式的編輯仍須等待它完成。 ### 流量與連線 diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts index 47fa6c632..e28130052 100644 --- a/e2e/nodes.spec.ts +++ b/e2e/nodes.spec.ts @@ -1,11 +1,14 @@ import {freshBackend} from './getting-started'; import type {Locator} from '@playwright/test'; import {editorText, box, loadingState} from './fixtures'; -import {expect, expectFittedGroupTags, fulfillStream, mockBackend, query, settle, test, moreAction, moreItem} from './fixtures'; +import {expect, expectFittedGroupTags, fulfillStream, loadCatalogues, mockBackend, query, settle, test, moreAction, moreItem} from './fixtures'; import {createMockApi} from '../mock'; import {ApiError} from '../src/api/error'; import {sha256} from '../src/api/hash'; import type {ProbeResult, Provider} from '../src/api/model'; +import {translate, type Translator} from '../src/i18n'; + +test.beforeAll(loadCatalogues); type ProbeResultItem = ProbeResult['results'][number]; @@ -155,6 +158,35 @@ for (const transport of ['xhttp', 'splithttp']) expect((await api.config()).sources.find(item => item.id === source.id)!.content).toBe(source.content.replace(link, updated)); }); +test('a link longer than 8192 characters is blocked on create but saved exactly on edit', async ({page}) => { + const {api, requests} = await mockBackend(page); + const source = (await api.config()).sources.find(source => source.kind === 'main')!; + const link = `vless://demo@hk-01.example.net:443?security=tls&path=${'x'.repeat(8193)}#hk-01`; + const tooLong = translate('en', 'nodes.linkTooLong', {n: 8192}); + await page.goto('/#/nodes?provider=inline'); + await page.getByRole('button', {name: 'Paste node link', exact: true}).click(); + const add = page.getByRole('dialog'); + await add.getByLabel('Name', {exact: true}).fill('long-node'); + await add.getByLabel('Node link', {exact: true}).fill(link); + await expect(add.getByRole('button', {name: 'Add', exact: true})).toBeDisabled(); + await expect(add.getByRole('button', {name: 'Add', exact: true})).toHaveAccessibleDescription(tooLong); + expect(requests.some(request => request.method() === 'POST' && new URL(request.url()).pathname === '/api/v1/nodes')).toBe(false); + await add.getByRole('button', {name: 'Cancel', exact: true}).click(); + await nodeRows(page).filter({hasText: 'hk-01'}).getByRole('button', {name: 'Node actions', exact: true}).click(); + await page.getByRole('menuitem', {name: 'Edit…', exact: true}).click(); + const edit = page.getByRole('dialog', {name: 'Edit node hk-01'}); + const original = await edit.getByLabel('Node link', {exact: true}).inputValue(); + await edit.getByLabel('Node link', {exact: true}).fill(link); + await expect(edit.getByRole('button', {name: 'Apply', exact: true})).toBeEnabled(); + await expect(edit.getByText(tooLong, {exact: true})).toHaveCount(0); + await edit.getByRole('button', {name: 'Apply', exact: true}).click(); + await expect(edit).toHaveCount(0); + const expected = source.content.replace(original, link); + const write = requests.find(request => request.method() === 'PUT' && new URL(request.url()).pathname === `/api/v1/config/sources/${source.id}`)!; + expect(write.postDataJSON().content).toBe(expected); + expect((await api.config()).sources.find(item => item.id === source.id)!.content).toBe(expected); +}); + test('XHTTP fields rewrite only their part of the link that is added', async ({page}) => { const {api, handlers, requests} = await mockBackend(page); handlers['POST nodes'] = request => api.createNode(request.postDataJSON()); @@ -180,12 +212,50 @@ test('XHTTP fields rewrite only their part of the link that is added', async ({p }); }); +test('XHTTP header rows trim names, block case-insensitive duplicates and preserve both values after a rename', async ({page}) => { + const {api, handlers, requests} = await mockBackend(page); + handlers['POST nodes'] = request => api.createNode(request.postDataJSON()); + const link = 'vless://00000000-0000-4000-8000-000000000001@example.com:443?security=tls&type=xhttp#edge'; + await page.goto('/#/nodes?provider=inline'); + await page.getByRole('button', {name: 'Paste node link', exact: true}).click(); + const add = page.getByRole('dialog'); + await add.getByLabel('Name', {exact: true}).fill('edge'); + await add.getByLabel('Node link', {exact: true}).fill(link); + await add.getByRole('button', {name: 'XHTTP options'}).click(); + await add.getByLabel('Header 1 value').fill('first'); + await add.getByLabel('Header 1 name').fill(' X-Test '); + const first = link.replace('#edge', `&extra=${encodeURIComponent(JSON.stringify({headers: {'X-Test': 'first'}}))}#edge`); + await expect(add.getByLabel('Node link', {exact: true})).toHaveValue(first); + await expect(add.getByLabel('Header 1 name')).toHaveValue(' X-Test '); + await add.getByLabel('Header 2 value').fill('second'); + await add.getByLabel('Header 2 name').fill('x-test'); + await expect(add.getByRole('button', {name: 'Add', exact: true})).toBeDisabled(); + const duplicate = translate('en', 'nodes.xhttp.headerDuplicate'); + await expect(add.getByLabel('Header 2 name')).toHaveAttribute('aria-invalid', 'true'); + await expect(add.getByLabel('Header 2 name')).toHaveAccessibleDescription(duplicate); + await expect(add.getByRole('group', {name: 'XHTTP options', exact: true}).getByText(duplicate, {exact: true})).toBeVisible(); + await expect(add.getByLabel('Header 1 value')).toHaveValue('first'); + await expect(add.getByLabel('Header 2 value')).toHaveValue('second'); + expect(requests.some(request => request.method() === 'POST' && new URL(request.url()).pathname === '/api/v1/nodes')).toBe(false); + await add.getByLabel('Header 2 name').fill('X-Other'); + const expected = link.replace('#edge', `&extra=${encodeURIComponent(JSON.stringify({headers: {'X-Test': 'first', 'X-Other': 'second'}}))}#edge`); + await expect(add.getByLabel('Node link', {exact: true})).toHaveValue(expected); + await add.getByRole('button', {name: 'Add', exact: true}).click(); + await expect(add).toHaveCount(0); + expect(requests.find(request => request.method() === 'POST' && new URL(request.url()).pathname === '/api/v1/nodes')!.postDataJSON()).toEqual({ + name: 'edge', + link: expected + }); + expect((await api.config()).sources.find(source => source.kind === 'main')!.content).toContain(expected); +}); + for (const [code, refused] of [ ['invalid-config-value', true], ['legacy-config-warning', false] ] as const) test(`an edited link that warns ${code} is ${refused ? 'refused before any write' : 'written'}`, async ({page}) => { - const {handlers, requests} = await mockBackend(page); + const {api, handlers, requests} = await mockBackend(page); + const source = (await api.config()).sources.find(source => source.kind === 'main')!; const reason = 'XHTTP xPaddingBytes must stay within 1-8192'; handlers['POST config/validate'] = async () => ({ valid: true, @@ -198,18 +268,132 @@ for (const [code, refused] of [ await row.getByRole('button', {name: 'Node actions', exact: true}).click(); await page.getByRole('menuitem', {name: 'Edit…', exact: true}).click(); const edit = page.getByRole('dialog', {name: 'Edit node jp-01'}); + const original = await edit.getByLabel('Node link', {exact: true}).inputValue(); await edit.getByRole('button', {name: 'XHTTP options'}).click(); await edit.getByLabel('Padding bytes (xPaddingBytes)').fill('0-10'); + const updated = await edit.getByLabel('Node link', {exact: true}).inputValue(); await edit.getByRole('button', {name: 'Apply', exact: true}).click(); if (refused) { - await expect(edit.getByRole('alert')).toContainText(`honk cannot read this link: ${reason}`); + await expect(edit.getByRole('alert')).toContainText(reason); + await expect(edit.getByLabel('Node link', {exact: true})).toHaveValue(updated); + expect((await api.config()).sources.find(item => item.id === source.id)!.content).toBe(source.content); expect(requests.some(request => request.method() === 'PUT')).toBe(false); } else { await expect(edit).toHaveCount(0); - expect(requests.some(request => request.method() === 'PUT')).toBe(true); + expect((await api.config()).sources.find(item => item.id === source.id)!.content).toBe(source.content.replace(original, updated)); } }); +for (const lang of ['zh-CN', 'zh-TW'] as const) + test.describe(`edited link rejection in ${lang}`, () => { + test.use({storage: {'doona-lang': lang}}); + const t: Translator = (key, params) => translate(lang, key, params); + for (const message of ['', 'XHTTP padding must stay within 1-8192']) + test(`a known diagnostic ${message ? 'retains backend detail' : 'with an empty message still rejects'} without writing`, async ({page}) => { + const {api, handlers, requests} = await mockBackend(page); + const source = (await api.config()).sources.find(source => source.kind === 'main')!; + handlers['POST config/validate'] = async () => ({ + valid: true, + diagnostics: [{level: 'warning', source_id: 'node', line: 2, column: 3, span: null, code: 'unsupported_value', message}], + generation_id: 'mock:1', + validated_at: new Date().toISOString() + }); + await page.goto('/#/nodes?provider=inline'); + await nodeRows(page) + .filter({hasText: 'jp-01'}) + .getByRole('button', {name: t('nodes.actions'), exact: true}) + .click(); + await page.getByRole('menuitem', {name: t('nodes.editAction'), exact: true}).click(); + const edit = page.getByRole('dialog', {name: t('nodes.editNodeTitle', {name: 'jp-01'})}); + const updated = 'vless://demo@example.com:443?security=tls&type=xhttp&extra=%7B%22xPaddingBytes%22%3A%220-10%22%7D#jp-01'; + await edit.getByLabel(t('nodes.link'), {exact: true}).fill(updated); + await edit.getByRole('button', {name: t('policy.save'), exact: true}).click(); + const summary = t('ui.backend.unsupportedValue'); + const reason = message ? t('ui.valuePair', {label: summary, value: message}) : summary; + await expect(edit.getByRole('alert')).toContainText(t('nodes.linkRejected', {reason})); + await expect(edit.getByLabel(t('nodes.link'), {exact: true})).toHaveValue(updated); + expect(requests.some(request => request.method() === 'PUT')).toBe(false); + expect((await api.config()).sources.find(item => item.id === source.id)!.content).toBe(source.content); + }); + }); + +test('an unnamed XHTTP header protects an existing node draft until the raw link is replaced', async ({page}) => { + await mockBackend(page); + await page.goto('/#/nodes?provider=inline'); + await nodeRows(page).filter({hasText: 'jp-01'}).getByRole('button', {name: 'Node actions', exact: true}).click(); + await page.getByRole('menuitem', {name: 'Edit…', exact: true}).click(); + const edit = page.getByRole('dialog', {name: 'Edit node jp-01'}); + const link = edit.getByLabel('Node link', {exact: true}); + const original = await link.inputValue(); + await edit.getByRole('button', {name: 'XHTTP options'}).click(); + await edit.getByLabel('Header 1 value').fill('pending'); + await expect(link).toHaveValue(original); + await page.evaluate(() => { + location.hash = '#/settings'; + }); + const discard = page.getByRole('alertdialog', {name: 'Discard changes not applied?'}); + await expect(discard).toBeVisible(); + await discard.getByRole('button', {name: 'Cancel', exact: true}).click(); + await expect(edit.getByLabel('Header 1 value')).toHaveValue('pending'); + await link.fill(original.replace('%2Fxhttp%2F', '%2Freplaced%2F')); + await expect(edit.getByLabel('Header 1 value')).toHaveValue(''); + await link.fill(original); + await page.evaluate(() => { + location.hash = '#/settings'; + }); + await expect(page.locator('.rp-nav[href="#/settings"]')).toHaveAttribute('aria-current', 'page'); + await expect(discard).toHaveCount(0); +}); + +test('empty node edit fields explain why applying is disabled', async ({page}) => { + await page.goto('/#/nodes?provider=inline'); + await nodeRows(page).filter({hasText: 'jp-01'}).getByRole('button', {name: 'Node actions', exact: true}).click(); + await page.getByRole('menuitem', {name: 'Edit…', exact: true}).click(); + const edit = page.getByRole('dialog', {name: 'Edit node jp-01'}); + const apply = edit.getByRole('button', {name: 'Apply', exact: true}); + await edit.getByLabel('Name', {exact: true}).fill(''); + await expect(apply).toBeDisabled(); + await expect(edit.getByLabel('Name', {exact: true})).toHaveAccessibleDescription(translate('en', 'nodes.nameMissing')); + await expect(apply).toHaveAccessibleDescription(translate('en', 'nodes.nameMissing')); + await edit.getByLabel('Name', {exact: true}).fill('jp-01'); + await edit.getByLabel('Node link', {exact: true}).fill(''); + await expect(apply).toBeDisabled(); + await expect(edit.getByLabel('Node link', {exact: true})).toHaveAccessibleDescription(translate('en', 'nodes.linkInvalid')); + await expect(apply).toHaveAccessibleDescription(translate('en', 'nodes.linkInvalid')); +}); + +test('a node preflight keeps shared source actions busy after its dialog closes', async ({page}) => { + const {api, handlers} = await mockBackend(page); + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + handlers['POST config/validate'] = async request => { + const candidate = request.postDataJSON(); + if (candidate.mode === 'syntax') await gate; + return api.validateConfig(candidate); + }; + await page.goto('/#/nodes?provider=inline'); + await nodeRows(page).filter({hasText: 'jp-01'}).getByRole('button', {name: 'Node actions', exact: true}).click(); + await page.getByRole('menuitem', {name: 'Edit…', exact: true}).click(); + const edit = page.getByRole('dialog', {name: 'Edit node jp-01'}); + const link = edit.getByLabel('Node link', {exact: true}); + const updated = (await link.inputValue()).replace('%2Fxhttp%2F', '%2Fupdated%2F'); + await link.fill(updated); + const checking = page.waitForRequest( + request => request.method() === 'POST' && request.url().endsWith('/config/validate') && request.postDataJSON().mode === 'syntax' + ); + await edit.getByRole('button', {name: 'Apply', exact: true}).click(); + await checking; + await edit.getByRole('button', {name: 'Cancel', exact: true}).click(); + await page.locator('.rp-nav[href="#/policies"]').click(); + await expect(page.getByRole('button', {name: 'New group', exact: true})).toBeDisabled(); + release(); + await expect(page.locator('.rp-toast.positive', {hasText: 'Saved jp-01'})).toBeVisible(); + await expect.poll(async () => (await api.config()).sources.find(source => source.kind === 'main')!.content).toContain(updated); + await expect(page.getByRole('button', {name: 'New group', exact: true})).toBeEnabled(); +}); + test('include declarations block removal even when the main source also declares them', async ({page}) => { const {api} = await mockBackend(page); const reason = 'Declared outside the main configuration file. Remove it from that file.'; diff --git a/src/dae/nodes.test.ts b/src/dae/nodes.test.ts index d974eb0d0..fb67c20ec 100644 --- a/src/dae/nodes.test.ts +++ b/src/dae/nodes.test.ts @@ -27,6 +27,13 @@ it('quotes a node renamed to mux, which honk would read bare as the removed stan ); }); +it('quotes ordinary renamed declaration keys while keeping references bare', () => { + const [entry] = readNodeEntries(text); + expect(writeNodeEntry(text, entry, {name: 'new', link: entry.link})).toBe( + text.replace('"old"', "'new'").replace("name('old',", 'name(new,').replace('!name(old)', '!name(new)') + ); +}); + it.each(['default', 'final'])('renames exact %s references and detects cross-source blockers', field => { const group = `group {\r\n proxy {\r\n ${field}: 'old' # keep\r\n policy: fixed(0)\r\n }\r\n}\r\n`; const input = text + group; diff --git a/src/dae/nodes.ts b/src/dae/nodes.ts index c4711f073..860533780 100644 --- a/src/dae/nodes.ts +++ b/src/dae/nodes.ts @@ -4,9 +4,6 @@ import {quoteName} from './groups'; export type NodeEntry = {name: string; link: string; from: number; to: number; line: number; nameTo: number; linkFrom: number}; export const isNodeLink = (value: string) => /^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(value.trim()); -// A node declaration's key as written: honk reads a bare `mux:` line as the removed standalone mux setting, so that -// one name keeps its quotes. -export const nodeKey = (name: string) => (name === 'mux' ? quote(name) : quoteName(name)); // Only named scalar entries have an unambiguous identity without decoding a protocol's share link. export function readNodeEntries(text: string): NodeEntry[] { @@ -74,7 +71,7 @@ export function writeNodeEntry(text: string, original: NodeEntry, next: {name: s if (next.name !== original.name && readNodeEntries(text).some(entry => entry.name === next.name)) throw new LocalError('nodes.nameTaken'); const entry = entries[0]; const changes = []; - if (next.name !== original.name) changes.push({from: entry.from, to: entry.nameTo, text: nodeKey(next.name)}); + if (next.name !== original.name) changes.push({from: entry.from, to: entry.nameTo, text: quote(next.name)}); if (next.link !== original.link) changes.push({from: entry.linkFrom, to: entry.to, text: quote(next.link)}); if (next.name !== original.name) changes.push(...nameReferences(text, original.name).map(ref => ({...ref, text: quoteName(next.name)}))); return changes.sort((a, b) => b.from - a.from).reduce((out, change) => out.slice(0, change.from) + change.text + out.slice(change.to), text); diff --git a/src/dae/xhttp.test.ts b/src/dae/xhttp.test.ts index bc4b4a589..6de247ac7 100644 --- a/src/dae/xhttp.test.ts +++ b/src/dae/xhttp.test.ts @@ -1,5 +1,6 @@ import {expect, it} from 'vitest'; -import {readXhttp, writeXhttp} from './xhttp'; +import {readXhttp, writeXhttp, type XhttpFields} from './xhttp'; +import {readNodeEntries, writeNodeEntry} from './nodes'; const extra = (value: unknown) => `extra=${encodeURIComponent(JSON.stringify(value))}`; const base = 'vless://00000000-0000-4000-8000-000000000001@example.com:443'; @@ -9,9 +10,11 @@ it.each([ `${base}?security=tls&type=xhttp&path=%2Fx%252Fy&extra=%7B%22headers%22%3A%7B%22X-Test%22%3A%22a%2Bb%22%7D%7D#edge`, `trojan://pw@example.com:443?type=splithttp&alpn=h2,http/1.1&fp=chrome&mode=stream-up#a%20b`, `${base}?type=xhttp&${extra({xmux: {maxConcurrency: '1'}, xPaddingBytes: {min: 1, max: 9}})}`, - ` ${base}?type=xhttp&path=%2Fx#typed with blanks ` + ` ${base}?type=xhttp&&path=%2Fx&#typed with blanks ` ])('writes nothing back when nothing changed: %s', link => { - expect(writeXhttp(link, readXhttp(link)!)).toBe(link); + expect(writeXhttp(link, {})).toBe(link); + const fields = readXhttp(link)!; + expect(writeXhttp(link, {path: fields.path, xPaddingBytes: fields.xPaddingBytes})).toBe(link); }); it('reads query claims over extra and decodes the literal path once', () => { @@ -22,7 +25,7 @@ it('reads query claims over extra and decodes the literal path once', () => { it('rewrites only the changed option and keeps unknown extra members', () => { const head = `${base}?security=tls&type=xhttp&alpn=h2,http/1.1&sni=a.example`; const link = `${head}&${extra({xmux: {maxConcurrency: '1'}, headers: {'X-A': 'a+b'}})}#edge`; - const next = writeXhttp(link, {...readXhttp(link)!, xPaddingBytes: '200-400', scMinPostsIntervalMs: '10'}); + const next = writeXhttp(link, {xPaddingBytes: '200-400', scMinPostsIntervalMs: '10'}); expect(next.startsWith(`${head}&extra=`)).toBe(true); expect(next.endsWith('#edge')).toBe(true); expect(decoded(next)).toEqual({xmux: {maxConcurrency: '1'}, headers: {'X-A': 'a+b'}, xPaddingBytes: '200-400', scMinPostsIntervalMs: 10}); @@ -31,16 +34,16 @@ it('rewrites only the changed option and keeps unknown extra members', () => { it('moves an edited request field to the query and drops its stale extra copy', () => { const link = `${base}?type=xhttp&${extra({path: '/old/', mode: 'packet-up'})}`; - const next = writeXhttp(link, {...readXhttp(link)!, path: '/new path/'}); + const next = writeXhttp(link, {path: '/new path/'}); expect(next).toBe(`${base}?type=xhttp&${extra({mode: 'packet-up'})}&path=%2Fnew%20path%2F`); - const cleared = writeXhttp(next, {...readXhttp(next)!, mode: '', headers: [['', 'dropped']]}); + const cleared = writeXhttp(next, {mode: '', headers: [['', 'dropped']]}); expect(cleared).toBe(`${base}?type=xhttp&path=%2Fnew%20path%2F`); }); it('writes a download peer honk can admit and keeps its other members', () => { const link = `${base}?type=xhttp&${extra({downloadSettings: {address: 'old.example', port: 443, network: 'xhttp', security: 'tls', alpn: ['h2'], tlsSettings: {serverName: 'sni.example', fingerprint: 'chrome'}}})}`; const download = {address: 'down.example', port: '8443', serverName: '', path: '/down/', host: ''}; - expect(decoded(writeXhttp(link, {...readXhttp(link)!, download})).downloadSettings).toEqual({ + expect(decoded(writeXhttp(link, {download})).downloadSettings).toEqual({ address: 'down.example', port: 8443, network: 'xhttp', @@ -49,7 +52,7 @@ it('writes a download peer honk can admit and keeps its other members', () => { tlsSettings: {fingerprint: 'chrome'}, xhttpSettings: {path: '/down/'} }); - expect(decoded(writeXhttp(link, {...readXhttp(link)!, download: null}))).toEqual({}); + expect(decoded(writeXhttp(link, {download: null}))).toEqual({}); }); it.each([ @@ -64,23 +67,81 @@ it.each([ `${base}?type=xhttp&${extra({downloadSettings: {address: 'd.example', port: 443, tlsSettings: 'x'}})}` ])('leaves links it cannot edit field by field alone: %s', link => { expect(readXhttp(link)).toBeNull(); - expect( - writeXhttp(link, { - mode: 'stream-one', - path: '', - host: '', - headers: [], - xPaddingBytes: '', - noGRPCHeader: false, - scMaxEachPostBytes: '', - scMinPostsIntervalMs: '', - download: null - }) - ).toBe(link); + expect(writeXhttp(link, {mode: 'stream-one'})).toBe(link); }); it('writes nothing for header rows that are still waiting for a name', () => { const link = `${base}?type=xhttp&extra=%7B%20%22headers%22%3A%20%7B%22X-A%22%3A%20%22a%22%7D%7D`; const fields = readXhttp(link)!; - expect(writeXhttp(link, {...fields, headers: [...fields.headers, ['', 'pending']]})).toBe(link); + expect(writeXhttp(link, {headers: [...fields.headers, ['', 'pending']]})).toBe(link); +}); + +it.each(['X-Test', 'x-test', ' X-TEST '])('keeps duplicate header %s pending until its name is unique', duplicate => { + const link = `${base}?type=xhttp&${extra({headers: {'X-Existing': 'keep'}})}`; + const headers: XhttpFields['headers'] = [ + [' X-Test ', 'first'], + [duplicate, 'second'], + ['', 'pending'] + ]; + expect(writeXhttp(link, {headers})).toBe(link); + headers[1][0] = ' X-Other '; + const next = writeXhttp(link, {headers}); + expect(decoded(next).headers).toEqual({'X-Test': 'first', 'X-Other': 'second'}); + headers[2][0] = ' X-Pending '; + expect(readXhttp(writeXhttp(next, {headers}))!.headers).toEqual([ + ['X-Test', 'first'], + ['X-Other', 'second'], + ['X-Pending', 'pending'] + ]); +}); + +it('keeps an explicit empty headers object while a row is waiting for its name', () => { + const link = `${base}?type=xhttp&extra=%7B%20%22headers%22%3A%20%7B%7D%7D`; + const headers: Array<[string, string]> = [[' ', 'pending']]; + expect(writeXhttp(link, {headers})).toBe(link); + expect(decoded(writeXhttp(link, {headers, xPaddingBytes: '200'}))).toEqual({headers: {}, xPaddingBytes: 200}); + expect(headers).toEqual([[' ', 'pending']]); +}); + +it('does not block unrelated patches on duplicate headers already present in a link', () => { + const headers = {'X-Test': 'first', ' x-test ': 'second'}; + const link = `${base}?type=xhttp&${extra({headers})}`; + expect(decoded(writeXhttp(link, {xPaddingBytes: '200'}))).toEqual({headers, xPaddingBytes: 200}); +}); + +it('keeps an unchanged apostrophe header quote-safe when editing padding in a node', () => { + const link = `${base}?type=xhttp&unknown=a+b%2f&${extra({headers: {'X-Author': "O'Reilly"}}).replace(/'/g, '%27')}`; + const source = `node {\n edge: '${link}'\n}\n`; + const [entry] = readNodeEntries(source); + const next = writeXhttp(entry.link, {xPaddingBytes: '200'}); + expect(next).toContain('unknown=a+b%2f&'); + expect(next).toContain('O%27Reilly'); + expect(next).not.toContain("'"); + const written = writeNodeEntry(source, entry, {name: entry.name, link: next}); + expect(readNodeEntries(written)[0].link).toBe(next); + expect(decoded(next)).toEqual({headers: {'X-Author': "O'Reilly"}, xPaddingBytes: 200}); +}); + +it('updates only the supplied download fields and preserves known and unknown siblings', () => { + const downloadSettings = { + address: 'down.example', + port: '0443', + network: 'xhttp', + security: 'tls', + alpn: ['h2'], + tlsSettings: {serverName: 'sni.example', fingerprint: 'chrome'}, + xhttpSettings: {path: '/down/', host: 'cdn.example', xmux: {maxConcurrency: 8}} + }; + const link = `${base}?type=xhttp&${extra({downloadSettings})}`; + expect(writeXhttp(link, {download: {path: '/down/'}})).toBe(link); + expect(decoded(writeXhttp(link, {download: {path: '/new/'}})).downloadSettings).toEqual({ + ...downloadSettings, + xhttpSettings: {...downloadSettings.xhttpSettings, path: '/new/'} + }); + expect(decoded(writeXhttp(`${base}?type=xhttp`, {download: {address: 'new.example', port: '443'}})).downloadSettings).toEqual({ + network: 'xhttp', + security: 'tls', + address: 'new.example', + port: 443 + }); }); diff --git a/src/dae/xhttp.ts b/src/dae/xhttp.ts index 7a2dc3008..adc1e9ac7 100644 --- a/src/dae/xhttp.ts +++ b/src/dae/xhttp.ts @@ -1,6 +1,7 @@ -// XHTTP options of a vless or trojan share link: the request fields in its query, the rest in its Xray `extra` -// JSON. The link stays the only copy: a write changes just the parameters whose shown value changed, so every other -// byte survives, and honk alone decides what is valid. +// XHTTP options of a vless or trojan share link: request fields in its query, the rest in its Xray `extra` JSON. +// Writes preserve unchanged query parameter strings, but may normalize empty separators or surrounding blanks. +// Changing extra uses JSON.parse/JSON.stringify: whitespace is normalized and numbers use JavaScript precision. +// The link stays the only saved copy, and honk alone decides what is valid. export type XhttpDownload = {address: string; port: string; serverName: string; path: string; host: string}; export type XhttpFields = { @@ -14,6 +15,7 @@ export type XhttpFields = { scMinPostsIntervalMs: string; download: XhttpDownload | null; }; +export type XhttpChange = Partial> & {download?: Partial | null}; export const xhttpModes = ['auto', 'packet-up', 'stream-up', 'stream-one']; const ranges = ['xPaddingBytes', 'scMaxEachPostBytes', 'scMinPostsIntervalMs'] as const; @@ -100,47 +102,81 @@ export function readXhttp(link: string): XhttpFields | null { return parse(link)?.current ?? null; } -export function writeXhttp(link: string, next: XhttpFields): string { +// HTTP header names are case-insensitive; only later named rows offend, so the first stays editable too. +export function duplicateXhttpHeaders(headers: XhttpFields['headers']): Set { + const seen = new Set(); + const duplicates = new Set(); + headers.forEach(([name], index) => { + const key = name.trim().toLowerCase(); + if (!key) return; + if (seen.has(key)) duplicates.add(index); + seen.add(key); + }); + return duplicates; +} + +export function writeXhttp(link: string, change: XhttpChange): string { const parsed = parse(link); if (!parsed) return link; - const {current} = parsed; - const params = [...parsed.params]; - const extra = structuredClone(parsed.extra); + // Keep the original link until edited duplicate rows are corrected, without blocking unrelated patches. + if (change.headers && duplicateXhttpHeaders(change.headers).size) return link; + const {current, params, extra} = parsed; + const before = JSON.stringify(extra); + let changed = false; const setParam = (key: string, value: string) => { const at = params.findIndex(param => decode(param.split('=', 1)[0]) === key); - const param = `${key}=${encodeURIComponent(value)}`; + const param = `${key}=${encodeURIComponent(value).replace(/'/g, '%27')}`; if (!value) { - if (at >= 0) params.splice(at, 1); + if (at < 0) return; + params.splice(at, 1); } else if (at >= 0) params[at] = param; else params.push(param); + changed = true; }; for (const key of ['mode', 'path', 'host'] as const) { - if (next[key] === current[key]) continue; - setParam(key, next[key]); + const value = change[key]; + if (value === undefined || value === current[key]) continue; + setParam(key, value); // The query claim wins; a stale copy in extra would only resurface once the query claim is cleared. delete extra[key]; } - for (const key of ranges) if (next[key] !== current[key]) put(extra, key, scalar(next[key].trim())); - if (next.noGRPCHeader !== current.noGRPCHeader) put(extra, 'noGRPCHeader', next.noGRPCHeader); - // Only named headers can live in the link; rows still waiting for a name change nothing. - if (JSON.stringify(next.headers) !== JSON.stringify(current.headers)) put(extra, 'headers', Object.fromEntries(next.headers.filter(([name]) => name.trim()))); - if (!next.download) put(extra, 'downloadSettings', null); - else if (JSON.stringify(next.download) !== JSON.stringify(current.download)) { - const old = isObject(extra.downloadSettings) ? extra.downloadSettings : {}; - const settings: Json = {...old}; - const tls = isObject(old.tlsSettings) ? {...old.tlsSettings} : {}; - const xhttp = isObject(old.xhttpSettings) ? {...old.xhttpSettings} : {}; - put(tls, 'serverName', next.download.serverName); - put(xhttp, 'path', next.download.path); - put(xhttp, 'host', next.download.host); - // honk accepts only an H2-over-TLS XHTTP download peer, so those two members are not choices. - Object.assign(settings, {address: next.download.address, port: scalar(next.download.port), network: 'xhttp', security: 'tls'}); - put(settings, 'tlsSettings', tls); - put(settings, 'xhttpSettings', xhttp); + for (const key of ranges) { + const value = change[key]; + if (value !== undefined && value !== current[key]) put(extra, key, scalar(value.trim())); + } + if (change.noGRPCHeader !== undefined && change.noGRPCHeader !== current.noGRPCHeader) put(extra, 'noGRPCHeader', change.noGRPCHeader); + if (change.headers) { + // Compare only named rows: a pending row must not remove an explicitly empty headers object. + const headers = change.headers.map(([name, value]) => [name.trim(), value]).filter(([name]) => name); + if ( + headers.length !== current.headers.length || + headers.some(([name, value], index) => name !== current.headers[index][0] || value !== current.headers[index][1]) + ) + put(extra, 'headers', Object.fromEntries(headers)); + } + if (change.download === null) delete extra.downloadSettings; + else if (change.download !== undefined) { + // honk accepts only an H2-over-TLS XHTTP download peer; initialize those claims when creating one. + const settings: Json = isObject(extra.downloadSettings) ? extra.downloadSettings : {network: 'xhttp', security: 'tls'}; + for (const key of ['address', 'port'] as const) { + const value = change.download[key]; + if (value !== undefined && value !== current.download?.[key]) put(settings, key, key === 'port' ? scalar(value) : value); + } + for (const [key, group] of [ + ['serverName', 'tlsSettings'], + ['path', 'xhttpSettings'], + ['host', 'xhttpSettings'] + ] as const) { + const value = change.download[key]; + if (value === undefined || value === current.download?.[key]) continue; + const nested = isObject(settings[group]) ? settings[group] : {}; + put(nested, key, value); + put(settings, group, nested); + } extra.downloadSettings = settings; } - if (JSON.stringify(extra) !== JSON.stringify(parsed.extra)) setParam('extra', Object.keys(extra).length ? JSON.stringify(extra) : ''); - const written = `${parsed.head}${params.length ? `?${params.join('&')}` : ''}${parsed.fragment}`; - // Unchanged means the very same string, surrounding blanks included, so a caller can compare links directly. - return written === link.trim() ? link : written; + const serialized = JSON.stringify(extra); + if (serialized !== before) setParam('extra', Object.keys(extra).length ? serialized : ''); + // Unchanged means the very same string, surrounding blanks and separators included. + return changed ? `${parsed.head}${params.length ? `?${params.join('&')}` : ''}${parsed.fragment}` : link; } diff --git a/src/features/nodes/Nodes.tsx b/src/features/nodes/Nodes.tsx index 23c410f7c..b4c9762e3 100644 --- a/src/features/nodes/Nodes.tsx +++ b/src/features/nodes/Nodes.tsx @@ -21,9 +21,9 @@ import type {PageProps} from '../../shell/routes'; import {ProviderCards} from './ProviderCards'; import {NodeTable} from './NodeTable'; import {useNodesPage} from './useNodesPage'; -import {SubscriptionFields} from './SubscriptionFields'; -// Only a node dialog holding an XHTTP link uses these fields, so they load with the first node dialog. +// Dialog-only fields load when their editor opens, not with the node list. const XhttpOptions = lazy(() => import('./XhttpOptions').then(module => ({default: module.XhttpOptions}))); +const SubscriptionFields = lazy(() => import('./SubscriptionFields').then(module => ({default: module.SubscriptionFields}))); const dialogForm = 'nodes-dialog-form'; export function Nodes(props: PageProps) { const t = useT(); @@ -43,6 +43,10 @@ export function Nodes(props: PageProps) { problem, form, setForm, + setNodeLink, + xhttp, + headerDuplicates, + setXhttp, removing, dialogTitle, formValid, @@ -141,17 +145,19 @@ export function Nodes(props: PageProps) { {(dialog?.kind === 'provider' || dialog?.kind === 'editProvider') && (
{t(dialog.kind === 'provider' ? 'nodes.addProviderHelp' : 'nodes.editProviderHelp')} - + + + {renameGroups && ( <> @@ -188,11 +194,13 @@ export function Nodes(props: PageProps) { label={t('nodes.link')} value={form.value} placeholder="vless://…" - onChange={value => setForm({...form, value})} + onChange={setNodeLink} /> - - setForm({...form, value})} /> - + {xhttp && ( + + + + )} )} diff --git a/src/features/nodes/XhttpOptions.tsx b/src/features/nodes/XhttpOptions.tsx index 4e8b0746c..b271b3e48 100644 --- a/src/features/nodes/XhttpOptions.tsx +++ b/src/features/nodes/XhttpOptions.tsx @@ -1,29 +1,26 @@ -import {useState} from 'react'; import {useT} from '../../i18n'; import {Disclosure, LabeledSelect, Switch, TextField, Toolbar} from '../../ui/ui'; -import {readXhttp, writeXhttp, xhttpModes, type XhttpDownload, type XhttpFields} from '../../dae/xhttp'; +import {xhttpModes, type XhttpChange, type XhttpDownload, type XhttpFields} from '../../dae/xhttp'; -// The XHTTP options of the link being typed. Each change rewrites the link, which remains what is saved and what -// honk validates; a link these fields cannot represent shows none of them. -export function XhttpOptions({link, onChange, isDisabled}: {link: string; onChange: (link: string) => void; isDisabled?: boolean}) { +// The controller owns the draft, its validation and the link these fields update. +export function XhttpOptions({ + fields, + duplicates, + onChange: set, + isDisabled +}: { + fields: XhttpFields; + duplicates: ReadonlySet; + onChange: (change: XhttpChange) => void; + isDisabled?: boolean; +}) { const t = useT(); - const fields = readXhttp(link); - // A header without a name cannot live in the link yet, so typed rows stay here until they have one. Rows the link - // would not write back unchanged were edited elsewhere, and the link's own headers replace them. - const [rows, setRows] = useState>(fields?.headers ?? []); - if (fields && writeXhttp(link, {...fields, headers: rows}) !== link) setRows(fields.headers); - if (!fields) return null; - const set = (patch: Partial) => onChange(writeXhttp(link, {...fields, ...patch})); - const setDownload = (patch: Partial) => { - if (fields.download) set({download: {...fields.download, ...patch}}); - }; + const setDownload = (download: Partial) => set({download}); const mode = fields.mode || 'auto'; const setHeader = (index: number, name: string, value: string) => { - const next = [...rows]; + const next = [...fields.headers]; next[index] = [name, value]; - const kept = next.filter(([rowName, rowValue]) => rowName || rowValue); - setRows(kept); - set({headers: kept}); + set({headers: next.filter(([rowName, rowValue]) => rowName || rowValue)}); }; return ( @@ -46,13 +43,14 @@ export function XhttpOptions({link, onChange, isDisabled}: {link: string; onChan /> set({host})} /> - {[...rows, ['', ''] as [string, string]].map(([name, value], index) => ( + {[...fields.headers, ['', ''] as [string, string]].map(([name, value], index) => ( setHeader(index, next, value)} /> row.level === 'error' || (row.level === 'warning' && !row.code.startsWith('legacy-')))?.message ?? null; -} - export function runSubscriptionAction(query: string, action: {run: () => void} | null) { if (!action) return; replaceRoute('nodes', within(query, {editSubscription: null, editSubscriptionTag: null, focus: null})); @@ -77,6 +67,12 @@ export function useNodesPage({go, query}: PageProps) { const t = useT(); const [dialog, setDialog] = useState(null); const [form, setForm] = useState(blank); + const [headers, setHeaders] = useState(null); + const xhttp = useMemo(() => readXhttp(form.value), [form.value]); + const headerRows = headers ?? xhttp?.headers ?? []; + const headerDuplicates = duplicateXhttpHeaders(headerRows); + const headerError = headerDuplicates.size ? t('nodes.xhttp.headerDuplicate') : null; + const headerEdited = headers !== null && JSON.stringify(headers) !== JSON.stringify(xhttp?.headers ?? []); const session = useDialogSession(); const submitting = useRef(null); const [pendingDialog, setPendingDialog] = useState(null); @@ -91,7 +87,7 @@ export function useNodesPage({go, query}: PageProps) { : dialog?.kind === 'editNode' ? form.name !== dialog.entry.name || form.value !== dialog.entry.link : !!(form.name || form.value); - const guard = useDraftGuard(!!dialog && edited, () => { + const guard = useDraftGuard(!!dialog && (edited || headerEdited), () => { session.next(); setDialog(null); }); @@ -99,6 +95,7 @@ export function useNodesPage({go, query}: PageProps) { (next: NodeDialog) => { session.next(); setForm(blank); + setHeaders(null); setProblem(null); if (next.kind === 'editProvider') setForm({...blank, name: next.entry.tag, value: next.entry.url, agent: next.entry.ua ?? '', route: next.entry.route ?? ''}); @@ -310,12 +307,23 @@ export function useNodesPage({go, query}: PageProps) { return; } const link = form.value.trim(); - const rejected = link === dialog.entry.link ? null : await linkRejection(resources, form.name.trim(), link); - if (rejected) { - refuse(t('nodes.linkRejected', {reason: rejected})); - return; - } - const result = await apply(text => writeNodeEntry(text, dialog.entry, {name: form.name.trim(), link}), current); + const result = await apply(async (text, validate) => { + // The link check and source write share one save reservation, even after this dialog closes. + if ( + link !== dialog.entry.link && + offered(resources, 'config_validate', {whileLoading: false}) && + resources?.config_validate.modes?.includes('syntax') + ) { + const {diagnostics} = await validate({ + mode: 'syntax', + sources: [{id: 'node', content: `node {\n ${quote(form.name.trim())}: ${quote(link)}\n}\n`}] + }); + // honk skips unreadable links with a warning; accepted declarations only warn with legacy-* codes. + const rejected = diagnostics.find(row => row.level === 'error' || (row.level === 'warning' && !row.code.startsWith('legacy-'))); + if (rejected) throw new Error(t('nodes.linkRejected', {reason: oneLine(diagnosticMessage(rejected, t), t)})); + } + return writeNodeEntry(text, dialog.entry, {name: form.name.trim(), link}); + }, current); const problem = editProblem(result, t); if (problem) refuseNotice(problem); if (result.kind !== 'ok') return; @@ -385,16 +393,15 @@ export function useNodesPage({go, query}: PageProps) { const checkingRemoval = removal.checking; const nodeState = dialog?.kind === 'editNode' ? nodeEditState(sources, dialog.source, dialog.entry, nodeList ?? [], form, t) : null; const nodeNameError = nodeState?.nameError ?? null; - const nodeEditError = nodeState?.error ?? null; const formValid = dialog?.kind === 'editNode' - ? nodeState!.valid + ? nodeState!.valid && !headerError : dialog?.kind === 'editProvider' ? subscriptionEdit.valid : dialog?.kind === 'provider' ? isBareName(form.name.trim()) && !!form.value.trim() && subscriptionUrlError(form.value, t) === null && !agentError && intervalSeconds !== null : dialog?.kind === 'node' - ? form.name.trim() !== '' && !!form.value.trim() && nodeFormReason('node', form.name, form.value, t) === null + ? form.name.trim() !== '' && !!form.value.trim() && nodeFormReason('node', form.name, form.value, t) === null && !headerError : true; const subscription: SubscriptionDraft = {name: form.name, url: form.value, interval: form.interval, agent: form.agent, cache: form.cache, route: form.route}; // A new subscription shows each option the backend lists, with its default preselected or as the placeholder; an @@ -494,18 +501,30 @@ export function useNodesPage({go, query}: PageProps) { setForm: (next: typeof form) => { if (submitting.current !== dialog) setForm(next); }, + setNodeLink: (value: string) => { + if (submitting.current === dialog) return; + setHeaders(null); + setForm(previous => ({...previous, value})); + }, + xhttp: xhttp && {...xhttp, headers: headerRows}, + headerDuplicates, + setXhttp: (change: XhttpChange) => { + if (submitting.current === dialog) return; + if (change.headers !== undefined) setHeaders(change.headers); + setForm(previous => ({...previous, value: writeXhttp(previous.value, change)})); + }, removing, dialogTitle, formValid, nodeNameError, - nodeLinkError: dialog?.kind === 'node' || dialog?.kind === 'editNode' ? nodeLinkError(form.value, t) : null, + nodeLinkError: nodeState?.linkError ?? (dialog?.kind === 'node' ? nodeLinkError(form.value, t) : null), formReason: dialog?.kind === 'editNode' - ? (nodeEditError ?? nodeFormReason('node', form.name, form.value, t)) + ? (nodeState!.reason ?? headerError) : dialog?.kind === 'editProvider' ? // A name error is shown on its field alone. subscriptionEdit.reason - : nodeFormReason(dialog?.kind, form.name, form.value, t), + : (nodeFormReason(dialog?.kind, form.name, form.value, t) ?? headerError), submit, pending: dialog !== null && pendingDialog === dialog, // A write abandoned by Cancel still holds the node actions until it settles, so no dialog can submit meanwhile. diff --git a/src/features/nodes/view.test.ts b/src/features/nodes/view.test.ts index 38c6636f7..567d8ed9a 100644 --- a/src/features/nodes/view.test.ts +++ b/src/features/nodes/view.test.ts @@ -323,13 +323,6 @@ it('projects traffic without truncating counters and retains custom refresh inte }); }); -it('gives an expiry to the minute beside the full date and time', () => { - const at = new Date(2026, 9, 27, 16, 27, 25).toISOString(); - const row = providerRowView(provider('a', {expires_at: at}), undefined, 'en-US', t); - expect(row.expiresShort.replace(/\s/g, ' ')).toBe('10/27/26, 16:27'); - expect(row.expires.replace(/\s/g, ' ')).toBe('10/27/26, 16:27:25'); -}); - it('separates built-in outbounds from unattributed nodes and avoids provider id collisions', () => { const nodes = [ node('direct', {protocol: 'direct'}), @@ -492,14 +485,14 @@ const form = {name: entry.name, value: entry.link, interval: '', agent: '', cach it.each(['filter: name(hk-01)', 'default: hk-01', 'final: hk-01'])('blocks a cross-source rename through %s but allows a link edit', reference => { const other = {...main, id: 'other', kind: 'include' as const, content: `group {\n other {\n ${reference}\n policy: random\n }\n}\n`}; - expect(nodeEditState([main, other], main, entry, [], {...form, name: 'changed'}, t)).toMatchObject({valid: false, error: t('nodes.renameElsewhere')}); - expect(nodeEditState([main, other], main, entry, [], {...form, value: 'socks5://127.0.0.1:1080'}, t)).toMatchObject({valid: true, error: null}); + expect(nodeEditState([main, other], main, entry, [], {...form, name: 'changed'}, t)).toMatchObject({valid: false, reason: t('nodes.renameElsewhere')}); + expect(nodeEditState([main, other], main, entry, [], {...form, value: 'socks5://127.0.0.1:1080'}, t)).toMatchObject({valid: true, reason: null}); }); it.each(['hk-01', "'hk-01'"])('blocks a cross-source DNS detour rename through %s but allows a link edit', target => { const other = {...main, id: 'dns', kind: 'include' as const, content: `dns {\n upstream {\n remote: 'tcp://1.1.1.1:53' -> ${target}\n }\n}\n`}; - expect(nodeEditState([main, other], main, entry, [], {...form, name: 'changed'}, t)).toMatchObject({valid: false, error: t('nodes.renameElsewhere')}); - expect(nodeEditState([main, other], main, entry, [], {...form, value: 'socks5://127.0.0.1:1080'}, t)).toMatchObject({valid: true, error: null}); + expect(nodeEditState([main, other], main, entry, [], {...form, name: 'changed'}, t)).toMatchObject({valid: false, reason: t('nodes.renameElsewhere')}); + expect(nodeEditState([main, other], main, entry, [], {...form, value: 'socks5://127.0.0.1:1080'}, t)).toMatchObject({valid: true, reason: null}); expect( nodeEditState([main, {...other, content: other.content.replace(`-> ${target}`, '-> hk-010')}], main, entry, [], {...form, name: 'changed'}, t).valid ).toBe(true); @@ -514,6 +507,23 @@ it('assigns duplicate names to the name field and validates links and unchanged expect(nodeEditState([main], main, entry, [], {...form, name: 'edge one'}, t)).toMatchObject({valid: true, nameError: null}); }); +it('explains empty edit fields without imposing the node creation limit', () => { + expect(nodeEditState([main], main, entry, [], {...form, name: ' '}, t)).toMatchObject({ + valid: false, + nameError: t('nodes.nameMissing'), + reason: t('nodes.nameMissing') + }); + expect(nodeEditState([main], main, entry, [], {...form, value: ''}, t)).toMatchObject({ + valid: false, + linkError: t('nodes.linkInvalid'), + reason: t('nodes.linkInvalid') + }); + expect(nodeEditState([main], main, entry, [], {...form, value: `socks5://example.com:1080#${'x'.repeat(8192)}`}, t)).toMatchObject({ + valid: true, + reason: null + }); +}); + it.each([ [true, true, true, true, true], [false, true, true, true, false], diff --git a/src/features/nodes/view.ts b/src/features/nodes/view.ts index cfd35ca87..865310093 100644 --- a/src/features/nodes/view.ts +++ b/src/features/nodes/view.ts @@ -127,12 +127,14 @@ export function nodeEditState(sources: ConfigSource[], source: ConfigSource, ent renamed && (nodes.some(node => node.name === name) || sources.some(item => (item.kind === 'main' || item.kind === 'include') && readNodeEntries(item.content).some(entry => entry.name === name))); - const nameError = taken ? t('nodes.nameTaken') : !isQuotable(name) ? t('config.unquotable') : null; - const error = blocked ? t('nodes.renameElsewhere') : !isQuotable(form.value.trim()) ? t('config.unquotable') : null; + const nameError = !name ? t('nodes.nameMissing') : taken ? t('nodes.nameTaken') : !isQuotable(name) ? t('config.unquotable') : null; + const linkError = nodeLinkError(form.value, t) ?? (!form.value.trim() ? t('nodes.linkInvalid') : null); + const reason = nameError ?? linkError ?? (blocked ? t('nodes.renameElsewhere') : null); return { nameError, - error, - valid: !!name && isNodeLink(form.value) && !error && !nameError && (form.name !== entry.name || form.value !== entry.link) + linkError, + reason, + valid: reason === null && (form.name !== entry.name || form.value !== entry.link) }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 57165d1d8..f21b236e7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1114,6 +1114,7 @@ "nodes.xhttp.downloadPath": "Download path", "nodes.xhttp.downloadPort": "Port", "nodes.xhttp.downloadServerName": "Download TLS server name", + "nodes.xhttp.headerDuplicate": "Header names must be unique (case-insensitive).", "nodes.xhttp.headerName": "Header {n} name", "nodes.xhttp.headerValue": "Header {n} value", "nodes.xhttp.help": "These fields edit the link's XHTTP parameters and its extra JSON; empty fields use the defaults shown. honk checks the link when it is saved, and a changed option gives the node a new ID.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e7cfb688c..b41a1081b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -961,6 +961,7 @@ "nodes.xhttp.downloadPath": "下载路径", "nodes.xhttp.downloadPort": "端口", "nodes.xhttp.downloadServerName": "下载 TLS 服务器名", + "nodes.xhttp.headerDuplicate": "Header 名称不能重复(不区分大小写)。", "nodes.xhttp.headerName": "Header {n} 名称", "nodes.xhttp.headerValue": "Header {n} 值", "nodes.xhttp.help": "这些字段编辑链接中的 XHTTP 参数及其 extra JSON;留空即使用显示的默认值。保存时由 honk 校验链接,修改选项会使节点获得新的 ID。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 91c1161ea..e3ddb2717 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -961,6 +961,7 @@ "nodes.xhttp.downloadPath": "下載路徑", "nodes.xhttp.downloadPort": "連接埠", "nodes.xhttp.downloadServerName": "下載 TLS 伺服器名稱", + "nodes.xhttp.headerDuplicate": "Header 名稱不能重複(不區分大小寫)。", "nodes.xhttp.headerName": "Header {n} 名稱", "nodes.xhttp.headerValue": "Header {n} 值", "nodes.xhttp.help": "這些欄位編輯連結中的 XHTTP 參數及其 extra JSON;留空即使用顯示的預設值。儲存時由 honk 驗證連結,變更選項會讓節點取得新的 ID。", diff --git a/src/store/config.test.ts b/src/store/config.test.ts index 31c5601df..33d0c2f46 100644 --- a/src/store/config.test.ts +++ b/src/store/config.test.ts @@ -4,9 +4,10 @@ import {ApiError, LocalError} from '../api/error'; import {createMockApi} from '../../mock'; import {capabilities} from '../../mock/fixtures'; // The mocked react reads hookHarness, so testHelpers loads before the modules that import react. -import {hookHarness, stubVisibleDocument} from './testHelpers'; +import {deferred, hookHarness, stubVisibleDocument} from './testHelpers'; import {closestLimit, createSource, readConfigFresh, refusalOutcome, useConfigEditor, useConfigRevisionAction, withinLimits} from './config'; import {watchResource} from './resourceCore'; +import {actionCell} from './action'; vi.mock('react', async original => ({ ...(await original()), @@ -14,7 +15,8 @@ vi.mock('react', async original => ({ useCallback: (callback: unknown) => callback, useSyncExternalStore: (_subscribe: unknown, read: () => unknown) => read() })); -vi.mock('./runtime', () => ({useCapabilities: () => ({data: capabilities})})); +let editorCapabilities = capabilities; +vi.mock('./runtime', () => ({useCapabilities: () => ({data: editorCapabilities})})); const source = {id: 'main', content_sha256: 'aaa'}; const refused = new ApiError(412, 'precondition_failed', 'Precondition failed'); @@ -180,6 +182,131 @@ it.each([ } }); +it('holds the shared save action through async preflight and read-back, persisting only the first candidate', async () => { + hookHarness.reset(); + onTestFinished(() => { + hookHarness.unmount(); + vi.restoreAllMocks(); + }); + const api = createMockApi(); + const selection = vi.spyOn(apiSelection, 'getApi').mockReturnValue(api); + const main = (await api.config()).sources.find(source => source.kind === 'main')!; + const content = main.content! + '\n# first candidate\n'; + const preflight = deferred(); + const validateConfig = api.validateConfig.bind(api); + const validate = vi.spyOn(api, 'validateConfig').mockImplementation(async (request, signal) => { + if (request.mode === 'syntax') await preflight.promise; + return validateConfig(request, signal); + }); + const replace = vi.spyOn(api, 'replaceConfigSource'); + const readBack = deferred(); + const refetch = vi.fn(async () => { + await readBack.promise; + await api.config(); + return {ok: true}; + }); + const [first, second] = hookHarness.render(() => [ + useConfigEditor(refetch, {rethrow: true, shared: 'main-source-edit'}), + useConfigEditor(refetch, {rethrow: true, shared: 'main-source-edit'}) + ]); + hookHarness.runEffects(); + const cell = actionCell(api, 'main-source-edit'); + const applied = first.apply(main, async (text, check) => { + const result = await check({sources: [{id: main.id, content: text}], mode: 'syntax'}); + expect(result.valid).toBe(true); + return content; + }); + const competing = vi.fn(() => main.content! + '\n# competing candidate\n'); + expect(cell.snapshot().busy).toBe('save'); + expect(await second.apply(main, competing)).toBeUndefined(); + await vi.waitUntil(() => validate.mock.calls.length === 1); + expect(cell.snapshot().busy).toBe('save'); + expect(replace).not.toHaveBeenCalled(); + expect(await second.apply(main, competing)).toBeUndefined(); + selection.mockImplementation(() => { + throw new Error('The async action must retain its captured API'); + }); + preflight.resolve(); + await vi.waitUntil(() => refetch.mock.calls.length === 1, {timeout: 10000}); + expect(cell.snapshot().busy).toBe('save'); + expect(await second.apply(main, competing)).toBeUndefined(); + readBack.resolve(); + expect(await applied).toHaveProperty('result'); + expect(competing).not.toHaveBeenCalled(); + expect(cell.snapshot()).toEqual({busy: null, error: null}); + expect(validate.mock.calls.map(([request]) => request.mode)).toEqual(['syntax', 'full']); + expect(validate.mock.calls[1][1]).toBe(validate.mock.calls[0][1]); + expect(replace).toHaveBeenCalledExactlyOnceWith(main.id, content, `"${main.content_sha256}"`, validate.mock.calls[0][1]); + expect((await api.config()).sources.find(source => source.id === main.id)?.content).toBe(content); +}); + +it('does not write or publish an error when cancelled during async preparation', async () => { + hookHarness.reset(); + onTestFinished(() => { + hookHarness.unmount(); + vi.restoreAllMocks(); + }); + const api = createMockApi(); + vi.spyOn(apiSelection, 'getApi').mockReturnValue(api); + const main = (await api.config()).sources.find(source => source.kind === 'main')!; + const prepared = deferred(); + const release = deferred(); + const validate = vi.spyOn(api, 'validateConfig'); + const replace = vi.spyOn(api, 'replaceConfigSource'); + const refetch = vi.fn(); + const editor = hookHarness.render(() => useConfigEditor(refetch, {rethrow: true, shared: 'main-source-edit'})); + hookHarness.runEffects(); + const applied = editor.apply({...main, path: ''}, async (text, check) => { + await check({sources: [{id: main.id, content: text}], mode: 'syntax'}); + prepared.resolve(); + await release.promise; + return text + '\n# cancelled candidate\n'; + }); + await prepared.promise; + editor.cancel(); + release.resolve(); + expect(await applied).toBeUndefined(); + expect(validate.mock.calls[0][1]?.aborted).toBe(true); + expect(actionCell(api, 'main-source-edit').snapshot()).toEqual({busy: null, error: null}); + expect(replace).not.toHaveBeenCalled(); + expect(refetch).not.toHaveBeenCalled(); + expect((await api.config()).sources.find(source => source.id === main.id)?.content).toBe(main.content); +}); + +it.each(['content', 'body'] as const)('enforces the advertised validation %s limit during preparation without sending or writing', async limit => { + hookHarness.reset(); + onTestFinished(() => { + hookHarness.unmount(); + vi.restoreAllMocks(); + editorCapabilities = capabilities; + }); + const api = createMockApi(); + vi.spyOn(apiSelection, 'getApi').mockReturnValue(api); + const main = (await api.config()).sources.find(source => source.kind === 'main')!; + editorCapabilities = { + ...capabilities, + ...(limit === 'content' + ? {resources: {...capabilities.resources, config_validate: {...capabilities.resources.config_validate, max_bytes: 4}}} + : {limits: {...capabilities.limits, max_json_body_bytes: 4}}) + }; + const validate = vi.spyOn(api, 'validateConfig'); + const replace = vi.spyOn(api, 'replaceConfigSource'); + const refetch = vi.fn(); + const editor = hookHarness.render(() => useConfigEditor(refetch, {rethrow: true, shared: 'main-source-edit'})); + hookHarness.runEffects(); + await expect( + editor.apply(main, async (text, check) => { + await check({sources: [{id: main.id, content: 'global {}'}], mode: 'syntax'}); + return text + '\n# oversized preflight\n'; + }) + ).rejects.toMatchObject({status: 413, text: {key: 'config.tooLarge', params: {limit: 4}}}); + expect(actionCell(api, 'main-source-edit').snapshot().busy).toBeNull(); + expect(validate).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); + expect(refetch).not.toHaveBeenCalled(); + expect((await api.config()).sources.find(source => source.id === main.id)?.content).toBe(main.content); +}); + // The write settles only once the configuration is read back, and a read that fails leaves the caller its draft. it.each([ {name: 'a delayed read-back', outcome: {ok: true as const}, operationFails: false, errorKey: null}, diff --git a/src/store/config.ts b/src/store/config.ts index eac25cb37..2a4ec494a 100644 --- a/src/store/config.ts +++ b/src/store/config.ts @@ -134,6 +134,11 @@ export function useConfigCreate(refetch: () => void) { }; } +export type ConfigTransform = ( + text: string, + validate: (request: ConfigValidationRequest) => Promise +) => string | null | Promise; + // `refetch` may return the read it starts: the write then settles once the new configuration is read back, so a caller that // drops its draft on success is not left showing the old text with the old digest. A read that fails after the write // landed ends the action with `ui.writtenNotRead`, so the draft stays until the read succeeds. @@ -169,13 +174,14 @@ export function useConfigEditor(refetch: () => unknown, {rethrow = false, shared [validateConfig, run] ), apply: useCallback( - (source: ConfigSource, candidate: string | ((text: string) => string | null)) => + (source: ConfigSource, candidate: string | ConfigTransform) => run('save', async signal => { setSourceId(source.id); if (!source.writable) throw new LocalError('config.readOnly'); if (!(await completeSource(source))) throw new LocalError('config.incomplete'); signal.throwIfAborted(); - const content = typeof candidate === 'string' ? candidate : candidate(source.content!); + const content = typeof candidate === 'string' ? candidate : await candidate(source.content!, request => validateConfig(request, signal)); + signal.throwIfAborted(); if (content === null) return undefined; if (canValidate && source.kind === 'main' && source.path !== '') { const check = await validateConfig({sources: [{id: source.id, path: source.path, content}], mode: 'full'}, signal); diff --git a/src/store/mainSource.ts b/src/store/mainSource.ts index edf4720eb..42b41137c 100644 --- a/src/store/mainSource.ts +++ b/src/store/mainSource.ts @@ -1,12 +1,12 @@ import {useCallback, useMemo} from 'react'; import {useCapabilities} from './runtime'; -import {useConfig, useConfigEditor, useSourceComplete} from './config'; +import {useConfig, useConfigEditor, useSourceComplete, type ConfigTransform} from './config'; import type {ConfigSource} from '../api/model'; import {LocalError, failureNotice, type Notice} from '../api/error'; import type {Translator} from '../i18n'; import {offered} from '../api/capabilities'; -// Apply small main-source edits through one read, optional full validation, If-Match write, and reload sequence. +// Apply small main-source edits through shared preparation, optional full validation, If-Match write, and reload. export type MainSourceEdit = { main: ConfigSource | null; // Whether the backend lets this page write at all: configuration content and writes both offered. @@ -15,7 +15,7 @@ export type MainSourceEdit = { error: Error | null; retry: () => void; // Never rejects: a dialog shows the outcome inline, a background edit as a toast. - apply: (transform: (text: string) => string, origin?: ConfigSource) => Promise; + apply: (transform: ConfigTransform, origin?: ConfigSource) => Promise; }; export type EditResult = {kind: 'ok'} | {kind: 'invalid'; errors: number} | {kind: 'cancelled'} | {kind: 'failed'; error: unknown}; From 646c15eb6a061d7348470e37a7fdd39067ce3389 Mon Sep 17 00:00:00 2001 From: eries Date: Mon, 12 Oct 2026 00:32:11 +0800 Subject: [PATCH 5/5] fix(i18n): keep XHTTP help within startup budgets Shorten XHTTP help in all three complete catalogues while retaining the defaults, validation, node-ID and download-peer guidance. Keep the existing size budgets: CI's Node 22 gzip output exceeded login by 10 bytes and Activity by 42 bytes. Verify the production build and size gate with Node 22.23.3, matching CI, and inspect all three languages in the node dialog. --- changes/feat-xhttp-extra.md | 1 + src/i18n/locales/en.json | 4 ++-- src/i18n/locales/zh-CN.json | 4 ++-- src/i18n/locales/zh-TW.json | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/changes/feat-xhttp-extra.md b/changes/feat-xhttp-extra.md index 106b61a02..23842f7aa 100644 --- a/changes/feat-xhttp-extra.md +++ b/changes/feat-xhttp-extra.md @@ -6,3 +6,4 @@ Added - Adding a node refuses a link longer than the 8192 characters the API accepts; editing an existing declaration does not apply that creation limit, and clearing required edit fields explains why saving is disabled. - Changed XHTTP parameters encode single quotes so header values and paths remain writable in configuration text; partial download changes preserve untouched settings. - Renamed node declarations always quote their names, including `mux`, which honk otherwise reads as a removed standalone setting. +- XHTTP help is more concise in all three languages, retaining the existing startup download budgets. diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f21b236e7..9da3b9ed6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1109,7 +1109,7 @@ "nodes.viewNodes": "View nodes", "nodes.xhttp.download": "Separate download peer", "nodes.xhttp.downloadAddress": "Download address", - "nodes.xhttp.downloadHelp": "Xray downloadSettings: GET requests go to this TLS H2 endpoint and inherit nothing from the upload side.", + "nodes.xhttp.downloadHelp": "GET uses this TLS H2 peer without inheriting upload settings.", "nodes.xhttp.downloadHost": "Download HTTP host", "nodes.xhttp.downloadPath": "Download path", "nodes.xhttp.downloadPort": "Port", @@ -1117,7 +1117,7 @@ "nodes.xhttp.headerDuplicate": "Header names must be unique (case-insensitive).", "nodes.xhttp.headerName": "Header {n} name", "nodes.xhttp.headerValue": "Header {n} value", - "nodes.xhttp.help": "These fields edit the link's XHTTP parameters and its extra JSON; empty fields use the defaults shown. honk checks the link when it is saved, and a changed option gives the node a new ID.", + "nodes.xhttp.help": "Blank fields use the shown defaults. honk validates on save; changed options give the node a new ID.", "nodes.xhttp.host": "HTTP host", "nodes.xhttp.mode": "Mode", "nodes.xhttp.noGrpcHeader": "Omit the gRPC content type (noGRPCHeader)", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b41a1081b..b2d11eae7 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -956,7 +956,7 @@ "nodes.viewNodes": "查看节点", "nodes.xhttp.download": "独立下载端", "nodes.xhttp.downloadAddress": "下载地址", - "nodes.xhttp.downloadHelp": "Xray downloadSettings:GET 请求发往这个 TLS H2 端点,不继承上传端的任何设置。", + "nodes.xhttp.downloadHelp": "GET 使用此 TLS H2 端点,不继承上传设置。", "nodes.xhttp.downloadHost": "下载 HTTP host", "nodes.xhttp.downloadPath": "下载路径", "nodes.xhttp.downloadPort": "端口", @@ -964,7 +964,7 @@ "nodes.xhttp.headerDuplicate": "Header 名称不能重复(不区分大小写)。", "nodes.xhttp.headerName": "Header {n} 名称", "nodes.xhttp.headerValue": "Header {n} 值", - "nodes.xhttp.help": "这些字段编辑链接中的 XHTTP 参数及其 extra JSON;留空即使用显示的默认值。保存时由 honk 校验链接,修改选项会使节点获得新的 ID。", + "nodes.xhttp.help": "留空使用所示默认值。honk 保存时校验,修改选项会改变节点 ID。", "nodes.xhttp.host": "HTTP host", "nodes.xhttp.mode": "模式", "nodes.xhttp.noGrpcHeader": "省略 gRPC 内容类型(noGRPCHeader)", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index e3ddb2717..ef0622e34 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -956,7 +956,7 @@ "nodes.viewNodes": "檢視節點", "nodes.xhttp.download": "獨立下載端", "nodes.xhttp.downloadAddress": "下載位址", - "nodes.xhttp.downloadHelp": "Xray downloadSettings:GET 請求送往這個 TLS H2 端點,不繼承上傳端的任何設定。", + "nodes.xhttp.downloadHelp": "GET 使用此 TLS H2 端點,不繼承上傳設定。", "nodes.xhttp.downloadHost": "下載 HTTP host", "nodes.xhttp.downloadPath": "下載路徑", "nodes.xhttp.downloadPort": "連接埠", @@ -964,7 +964,7 @@ "nodes.xhttp.headerDuplicate": "Header 名稱不能重複(不區分大小寫)。", "nodes.xhttp.headerName": "Header {n} 名稱", "nodes.xhttp.headerValue": "Header {n} 值", - "nodes.xhttp.help": "這些欄位編輯連結中的 XHTTP 參數及其 extra JSON;留空即使用顯示的預設值。儲存時由 honk 驗證連結,變更選項會讓節點取得新的 ID。", + "nodes.xhttp.help": "留空使用所示預設值。honk 儲存時驗證,變更選項會改變節點 ID。", "nodes.xhttp.host": "HTTP host", "nodes.xhttp.mode": "模式", "nodes.xhttp.noGrpcHeader": "省略 gRPC 內容類型(noGRPCHeader)",