/** * 后端接口的客户端。 * * 令牌存在 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 } /** 一个订阅的运行时状态。updatedAt 是上次拉取成功的时刻,没拉过是 Go 的零值时间。 */ export interface ProviderInfo { vehicleType: string updatedAt?: string proxies: ProxyInfo[] } 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'), /** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */ kernelConfigs: () => request>('GET', '/api/kernel/configs'), /** * 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。 * * 不能用上面的 request():它会把整个响应读完,而对永不结束的流来说那等于挂死。 * 调用方拿到响应体后用 web/stream.ts 的 readLines() 逐行读;中止 signal 即断流。 */ openStream: async (path: string, signal: AbortSignal): Promise> => { const res = await fetch(`/api/kernel${path}`, { headers: { Authorization: `Bearer ${getToken()}` }, signal, }) if (!res.ok) { const text = await res.text() const payload = text ? (JSON.parse(text) as Record) : {} throw new ApiError(res.status, payload) } if (!res.body) throw new Error('响应没有内容') return res.body }, /** 入站类型的字段表,从内核源码提取,用来驱动表单。 */ 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'), /** * 让内核立刻重新拉取某个订阅。 * * 运行时动作,不写配置文件,所以不走 applyConfig 那条校验重载链路。成功时内核回 * 204 空响应,拉取失败回 503 带错误消息(内核源码 hub/route/provider.go:52-60)。 */ refreshProvider: (name: string) => request('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`), /** * 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。 * * 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张 * 全局名字表里,这是拿到它们类型和延迟的唯一途径。 */ providers: () => request<{ providers: Record }>('GET', '/api/kernel/providers/proxies'), }