From dd100cfc97a87e2f0143e9b57823056aea65ae76 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 20:06:54 +0800 Subject: [PATCH] =?UTF-8?q?web/api.ts=20=E8=A1=8C=E5=B0=BE=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=9B=9E=20LF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一版提交把整个文件以 CRLF 写进了 blob,内容与 main 相比只是追加, 但每一行都会在后续分支的合并里制造整文件冲突。 --- web/api.ts | 382 ++++++++++++++++++++++++++--------------------------- 1 file changed, 191 insertions(+), 191 deletions(-) diff --git a/web/api.ts b/web/api.ts index 03f7506..2d99cc2 100644 --- a/web/api.ts +++ b/web/api.ts @@ -1,191 +1,191 @@ -/** - * 后端接口的客户端。 - * - * 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1 - * (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。 - */ -import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' -import type { ConnectionSnapshot } from '@shared/connections.ts' - -const TOKEN_KEY = 'patchbay.token' - -/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */ -const TEST_URL = 'https://www.gstatic.com/generate_204' - -export function getToken(): string { - return localStorage.getItem(TOKEN_KEY) ?? '' -} - -export function setToken(value: string): void { - localStorage.setItem(TOKEN_KEY, value.trim()) -} - -export function clearToken(): void { - localStorage.removeItem(TOKEN_KEY) -} - -/** - * 接口报错时抛这个。 - * - * 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。 - * 界面直接用这两个字段,不需要认得每个端点各自的错误形状。 - */ -export class ApiError extends Error { - constructor( - readonly status: number, - readonly payload: Record, - ) { - super( - typeof payload.message === 'string' - ? payload.message - : `请求失败(HTTP ${String(status)})`, - ) - this.name = 'ApiError' - } - - /** 服务端给的下一步建议,没有就是空串。 */ - get hint(): string { - return typeof this.payload.hint === 'string' ? this.payload.hint : '' - } - - get unauthorized(): boolean { - return this.status === 401 - } -} - -async function request(method: string, path: string, body?: unknown): Promise { - const res = await fetch(path, { - method, - headers: { - Authorization: `Bearer ${getToken()}`, - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - body: body === undefined ? undefined : JSON.stringify(body), - }) - const text = await res.text() - const payload = text ? (JSON.parse(text) as Record) : {} - if (!res.ok) throw new ApiError(res.status, payload) - return payload as T -} - -export interface ConfigSnapshot { - data: ConfigObject - revision: string - diagnostics: ConfigDiagnostic[] -} - -export interface ApplySuccess { - ok: true - snapshot: ConfigSnapshot - reloaded: boolean - reloadMessage: string -} - -export interface ProxyInfo { - /** 出站的名字。/proxies 用键名标识,provider 里的线路则带在对象上 */ - name: string - /** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */ - type: string - /** 策略组当前选中的成员。不是策略组就没有这个字段 */ - now?: string - /** 策略组的成员列表 */ - all?: string[] - /** 延迟测试记录,最后一条是最近一次。delay 为 0 表示测失败 */ - history?: { delay: number }[] -} - -/** 最近一次延迟。没测过或测失败都返回 null。 */ -export function latestDelay(info: ProxyInfo | undefined): number | null { - const last = info?.history?.at(-1)?.delay - return last === undefined || last === 0 ? null : last -} - -export interface FieldSpec { - key: string - type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]' - required: boolean - default: unknown - enum: (string | number)[] | null - note: string -} - -export interface ListenerFields { - common: FieldSpec[] - types: Record -} - -export const api = { - readConfig: () => request('GET', '/api/config'), - - applyConfig: (data: ConfigObject, baseRevision: string) => - request('PUT', '/api/config', { data, baseRevision }), - - listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'), - - restoreBackup: (name: string) => - request('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`), - - kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), - - /** 入站类型的字段表,从内核源码提取,用来驱动表单。 */ - listenerFields: () => request('GET', '/api/meta/listener-fields'), - - /** - * 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。 - * 这些配置文件里都没有,只能问运行中的内核。 - */ - proxies: () => request<{ proxies: Record }>('GET', '/api/kernel/proxies'), - - /** - * 切换某个策略组当前选中哪个成员。 - * 这是运行时状态,不写配置文件,立刻生效——metacubexd 里点一条线路做的就是这件事。 - */ - selectProxy: (group: string, member: string) => - request<{ ok: true }>('PUT', `/api/kernel/proxies/${encodeURIComponent(group)}`, { - name: member, - }), - - /** - * 测一条出站的延迟。 - * - * 走哪个接口取决于它在不在内核的全局名字表里:策略组和自有线路在表里,用 /proxies; - * 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。 - */ - testLine: (name: string, provider?: string, timeout = 5000) => { - const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}` - const path = provider - ? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}` - : `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}` - return request<{ delay: number }>('GET', path) - }, - - /** - * 当前所有连接的快照。 - * - * 这个接口没有流式版本(流式的是 /traffic、/logs 那几个),只能全量轮询。 - * 没有连接时 connections 字段是 null 而不是空数组。 - */ - connections: () => request('GET', '/api/kernel/connections'), - - /** 断开某一条连接。运行时动作,不写配置文件。 */ - closeConnection: (id: string) => - request<{ message?: string }>( - 'DELETE', - `/api/kernel/connections/${encodeURIComponent(id)}`, - ), - - /** 断开所有连接。 */ - closeAllConnections: () => request<{ message?: string }>('DELETE', '/api/kernel/connections'), - - /** - * 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。 - * - * 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张 - * 全局名字表里,这是拿到它们类型和延迟的唯一途径。 - */ - providers: () => - request<{ providers: Record }>( - 'GET', - '/api/kernel/providers/proxies', - ), -} +/** + * 后端接口的客户端。 + * + * 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1 + * (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。 + */ +import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' +import type { ConnectionSnapshot } from '@shared/connections.ts' + +const TOKEN_KEY = 'patchbay.token' + +/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */ +const TEST_URL = 'https://www.gstatic.com/generate_204' + +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? '' +} + +export function setToken(value: string): void { + localStorage.setItem(TOKEN_KEY, value.trim()) +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +/** + * 接口报错时抛这个。 + * + * 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。 + * 界面直接用这两个字段,不需要认得每个端点各自的错误形状。 + */ +export class ApiError extends Error { + constructor( + readonly status: number, + readonly payload: Record, + ) { + super( + typeof payload.message === 'string' + ? payload.message + : `请求失败(HTTP ${String(status)})`, + ) + this.name = 'ApiError' + } + + /** 服务端给的下一步建议,没有就是空串。 */ + get hint(): string { + return typeof this.payload.hint === 'string' ? this.payload.hint : '' + } + + get unauthorized(): boolean { + return this.status === 401 + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(path, { + method, + headers: { + Authorization: `Bearer ${getToken()}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await res.text() + const payload = text ? (JSON.parse(text) as Record) : {} + if (!res.ok) throw new ApiError(res.status, payload) + return payload as T +} + +export interface ConfigSnapshot { + data: ConfigObject + revision: string + diagnostics: ConfigDiagnostic[] +} + +export interface ApplySuccess { + ok: true + snapshot: ConfigSnapshot + reloaded: boolean + reloadMessage: string +} + +export interface ProxyInfo { + /** 出站的名字。/proxies 用键名标识,provider 里的线路则带在对象上 */ + name: string + /** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */ + type: string + /** 策略组当前选中的成员。不是策略组就没有这个字段 */ + now?: string + /** 策略组的成员列表 */ + all?: string[] + /** 延迟测试记录,最后一条是最近一次。delay 为 0 表示测失败 */ + history?: { delay: number }[] +} + +/** 最近一次延迟。没测过或测失败都返回 null。 */ +export function latestDelay(info: ProxyInfo | undefined): number | null { + const last = info?.history?.at(-1)?.delay + return last === undefined || last === 0 ? null : last +} + +export interface FieldSpec { + key: string + type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]' + required: boolean + default: unknown + enum: (string | number)[] | null + note: string +} + +export interface ListenerFields { + common: FieldSpec[] + types: Record +} + +export const api = { + readConfig: () => request('GET', '/api/config'), + + applyConfig: (data: ConfigObject, baseRevision: string) => + request('PUT', '/api/config', { data, baseRevision }), + + listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'), + + restoreBackup: (name: string) => + request('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`), + + kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), + + /** 入站类型的字段表,从内核源码提取,用来驱动表单。 */ + listenerFields: () => request('GET', '/api/meta/listener-fields'), + + /** + * 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。 + * 这些配置文件里都没有,只能问运行中的内核。 + */ + proxies: () => request<{ proxies: Record }>('GET', '/api/kernel/proxies'), + + /** + * 切换某个策略组当前选中哪个成员。 + * 这是运行时状态,不写配置文件,立刻生效——metacubexd 里点一条线路做的就是这件事。 + */ + selectProxy: (group: string, member: string) => + request<{ ok: true }>('PUT', `/api/kernel/proxies/${encodeURIComponent(group)}`, { + name: member, + }), + + /** + * 测一条出站的延迟。 + * + * 走哪个接口取决于它在不在内核的全局名字表里:策略组和自有线路在表里,用 /proxies; + * 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。 + */ + testLine: (name: string, provider?: string, timeout = 5000) => { + const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}` + const path = provider + ? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}` + : `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}` + return request<{ delay: number }>('GET', path) + }, + + /** + * 当前所有连接的快照。 + * + * 这个接口没有流式版本(流式的是 /traffic、/logs 那几个),只能全量轮询。 + * 没有连接时 connections 字段是 null 而不是空数组。 + */ + connections: () => request('GET', '/api/kernel/connections'), + + /** 断开某一条连接。运行时动作,不写配置文件。 */ + closeConnection: (id: string) => + request<{ message?: string }>( + 'DELETE', + `/api/kernel/connections/${encodeURIComponent(id)}`, + ), + + /** 断开所有连接。 */ + closeAllConnections: () => request<{ message?: string }>('DELETE', '/api/kernel/connections'), + + /** + * 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。 + * + * 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张 + * 全局名字表里,这是拿到它们类型和延迟的唯一途径。 + */ + providers: () => + request<{ providers: Record }>( + 'GET', + '/api/kernel/providers/proxies', + ), +}