Files
mihomo-patchbay/web/api.ts
T
im0o 5bd4e8d609 合并规则页(task/rules-page)
# Conflicts:
#	web/api.ts
2026-08-11 20:08:44 +08:00

259 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 后端接口的客户端。
*
* 令牌存在 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<string, unknown>,
) {
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<T>(method: string, path: string, body?: unknown): Promise<T> {
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<string, unknown>) : {}
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<string, { note: string; fields: FieldSpec[] }>
}
export interface RuleProviderInfo {
/** domain / ipcidr / classical */
behavior: string
/** yaml / text / mrs。inline 类型的没有这个字段 */
format?: string
name: string
/** 当前装了多少条规则 */
ruleCount: number
/** HTTP / File / Inline */
vehicleType: string
/** RFC3339。还没拉取过时是零值(0001-01-01) */
updatedAt: string
}
export const api = {
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
applyConfig: (data: ConfigObject, baseRevision: string) =>
request<ApplySuccess>('PUT', '/api/config', { data, baseRevision }),
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
restoreBackup: (name: string) =>
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
/**
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
*
* 不能用上面的 request():它会把整个响应读完,而对永不结束的流来说那等于挂死。
* 调用方拿到响应体后用 web/stream.ts 的 readLines() 逐行读;中止 signal 即断流。
*/
openStream: async (path: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array>> => {
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<string, unknown>) : {}
throw new ApiError(res.status, payload)
}
if (!res.body) throw new Error('响应没有内容')
return res.body
},
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
/**
* 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。
* 这些配置文件里都没有,只能问运行中的内核。
*/
proxies: () => request<{ proxies: Record<string, ProxyInfo> }>('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<ConnectionSnapshot>('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<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`),
/**
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
* 配置文件里只有声明,这两个数字只有运行中的内核知道。
*/
ruleProviders: () =>
request<{ providers: Record<string, RuleProviderInfo> }>('GET', '/api/kernel/providers/rules'),
/**
* 让内核立刻重新拉取某个规则组。
* 这是运行时动作,不写配置文件,所以不走落盘那条保护链路。
*/
updateRuleProvider: (name: string) =>
request<Record<string, never>>(
'PUT',
`/api/kernel/providers/rules/${encodeURIComponent(name)}`,
),
/**
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
*
* 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张
* 全局名字表里,这是拿到它们类型和延迟的唯一途径。
*/
providers: () =>
request<{ providers: Record<string, ProviderInfo> }>('GET', '/api/kernel/providers/proxies'),
}