web/api.ts 行尾归一回 LF

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