Files

104 lines
3.8 KiB
JavaScript

// Minimal async client for the sing-box Clash API, built on libsoup 3.
//
// Endpoints used:
// GET /version liveness + version string
// GET /connections snapshot: totals + active connections
// GET /proxies all proxies and selector groups
// PUT /proxies/{group} {name} switch a Selector group's active node
// GET /proxies/{name}/delay?url=... measure latency of a node
import Soup from 'gi://Soup';
import GLib from 'gi://GLib';
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
export class ClashApi {
constructor({ url = 'http://127.0.0.1:9090', secret = '' } = {}) {
this._session = new Soup.Session();
// Above the 5 s /delay probe budget so libsoup's inactivity timeout
// doesn't abort a slow-but-alive latency measurement client-side.
this._session.timeout = 10;
this.setEndpoint(url, secret);
}
setEndpoint(url, secret) {
this._baseUrl = String(url || '').replace(/\/+$/, '');
this._secret = secret || '';
}
// Abort all in-flight requests and drop idle keep-alive connections.
destroy() {
this._session.abort();
}
_message(method, path, body) {
const uri = `${this._baseUrl}${path}`;
const msg = Soup.Message.new(method, uri);
if (!msg)
throw new Error(`Invalid API URL: ${uri}`);
const headers = msg.get_request_headers();
headers.append('Accept', 'application/json');
if (this._secret)
headers.append('Authorization', `Bearer ${this._secret}`);
if (body !== undefined) {
const bytes = new GLib.Bytes(textEncoder.encode(JSON.stringify(body)));
msg.set_request_body_from_bytes('application/json', bytes);
}
return msg;
}
_request(method, path, body, cancellable) {
return new Promise((resolve, reject) => {
const msg = this._message(method, path, body);
this._session.send_and_read_async(
msg,
GLib.PRIORITY_DEFAULT,
cancellable ?? null,
(session, res) => {
let bytes;
try {
bytes = session.send_and_read_finish(res);
} catch (e) {
reject(e);
return;
}
const status = msg.get_status();
if (status < 200 || status >= 300) {
reject(new Error(`HTTP ${status} ${msg.get_reason_phrase() ?? ''} (${path})`.trim()));
return;
}
try {
const data = bytes?.get_data();
const text = data && data.length ? textDecoder.decode(data) : '';
resolve(text ? JSON.parse(text) : null);
} catch (e) {
reject(e);
}
}
);
});
}
getVersion(cancellable) {
return this._request('GET', '/version', undefined, cancellable);
}
getConnections(cancellable) {
return this._request('GET', '/connections', undefined, cancellable);
}
getProxies(cancellable) {
return this._request('GET', '/proxies', undefined, cancellable);
}
selectProxy(group, name, cancellable) {
return this._request('PUT', `/proxies/${encodeURIComponent(group)}`, { name }, cancellable);
}
getDelay(name, { url = 'https://www.gstatic.com/generate_204', timeout = 3000 } = {}, cancellable) {
const query = `url=${encodeURIComponent(url)}&timeout=${timeout}`;
return this._request('GET', `/proxies/${encodeURIComponent(name)}/delay?${query}`, undefined, cancellable);
}
}