Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
|
||||
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
|
||||
|
||||
type RequestOptions = Omit<RequestInit, 'headers'> & {
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
interface JsonResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<unknown>;
|
||||
}
|
||||
|
||||
type FetchImplementation = (url: string, options: RequestOptions) => Promise<JsonResponse>;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export class HarborApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
retryable: boolean;
|
||||
details: unknown;
|
||||
correlationId: string;
|
||||
|
||||
constructor(payload: unknown = {}, status = 0) {
|
||||
const candidate = record(payload);
|
||||
const requestedCode = typeof candidate.code === 'string' ? candidate.code : '';
|
||||
const code = Object.hasOwn(ERROR_DEFINITIONS, requestedCode) ? requestedCode : 'UNKNOWN';
|
||||
const definition = errorDefinition(code);
|
||||
super(definition.message);
|
||||
this.name = 'HarborApiError';
|
||||
this.code = code;
|
||||
this.status = status >= 400 ? status : definition.status;
|
||||
this.retryable = definition.retryable;
|
||||
this.details = candidate.details;
|
||||
this.correlationId = typeof candidate.correlationId === 'string' && candidate.correlationId
|
||||
? candidate.correlationId
|
||||
: globalThis.crypto?.randomUUID?.() || new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export async function request(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
fetchImpl: FetchImplementation = fetch,
|
||||
): Promise<unknown> {
|
||||
let response: JsonResponse;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (record(error).name === 'AbortError') throw error;
|
||||
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
|
||||
}
|
||||
|
||||
let data: unknown = {};
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
|
||||
}
|
||||
const payload = record(data);
|
||||
if (!response.ok || payload.success === false) {
|
||||
const errorPayload = payload.error && typeof payload.error === 'object'
|
||||
? payload.error
|
||||
: { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' };
|
||||
throw new HarborApiError(errorPayload, response.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
version: () => request('/api/version'),
|
||||
subscription: {
|
||||
validate: (url: string, { signal }: { signal?: AbortSignal } = {}) => request(
|
||||
'/api/subscription/validate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
signal,
|
||||
},
|
||||
),
|
||||
fetch: (url: string) => request('/api/subscription/fetch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
|
||||
forget: () => request('/api/subscription', { method: 'DELETE' }),
|
||||
},
|
||||
apply: (serverId: string) => request('/api/apply', {
|
||||
method: 'POST',
|
||||
// selectedTag keeps this client compatible with pre-ID Harbor backends.
|
||||
body: JSON.stringify({ serverId, selectedTag: serverId }),
|
||||
}),
|
||||
gatewayAuto: {
|
||||
setEnabled: (enabled: boolean) => request('/api/gateway-auto', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
},
|
||||
routeRules: {
|
||||
update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rules, expectedRulesRevision }),
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
list: () => request('/api/devices'),
|
||||
refresh: () => request('/api/devices/refresh', { method: 'POST' }),
|
||||
update: (id: string, patch: Record<string, unknown>, expectedRevision: unknown) => request(
|
||||
`/api/devices/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...patch, expectedRevision }),
|
||||
},
|
||||
),
|
||||
setPolicy: (id: string, mode: unknown, expectedRevision: unknown) => request(
|
||||
`/api/devices/${id}/policy`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ mode, expectedRevision }),
|
||||
},
|
||||
),
|
||||
},
|
||||
diagnostics: {
|
||||
connectivity: (services: unknown[] = [], target: unknown = null) => request(
|
||||
'/api/diagnostics/connectivity',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ services, target }),
|
||||
},
|
||||
),
|
||||
},
|
||||
singbox: {
|
||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||
},
|
||||
servers: {
|
||||
ping: (serverIds: string[]) => request('/api/servers/ping-all', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ serverIds }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export interface HarborClientState extends StateSnapshot {
|
||||
clientRuntime: {
|
||||
proxyPort: number;
|
||||
configured: boolean;
|
||||
gatewayAvailable: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseHarborState(value: unknown): HarborClientState {
|
||||
let snapshot: StateSnapshot;
|
||||
try {
|
||||
snapshot = assertStateSnapshot(value);
|
||||
} catch (cause) {
|
||||
throw Object.assign(new Error('Ожидался Harbor state apiVersion 1', { cause }), {
|
||||
code: 'INCOMPATIBLE_API',
|
||||
});
|
||||
}
|
||||
const payload = record(value);
|
||||
const gatewayAuto = record(payload.gatewayAuto);
|
||||
const parsedProxyPort = Number(payload.proxyPort);
|
||||
const canonical: StateSnapshot = {
|
||||
apiVersion: snapshot.apiVersion,
|
||||
revision: snapshot.revision,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
mode: snapshot.mode,
|
||||
subscription: snapshot.subscription,
|
||||
selection: snapshot.selection,
|
||||
connection: snapshot.connection,
|
||||
route: snapshot.route,
|
||||
operation: snapshot.operation,
|
||||
servers: snapshot.servers,
|
||||
};
|
||||
return {
|
||||
...canonical,
|
||||
clientRuntime: {
|
||||
proxyPort: Number.isInteger(parsedProxyPort) && parsedProxyPort > 0
|
||||
? parsedProxyPort
|
||||
: snapshot.mode === 'gateway' ? 8080 : 8082,
|
||||
configured: payload.configExists === true,
|
||||
gatewayAvailable: gatewayAuto.available === true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const harborClient = {
|
||||
async getState(): Promise<HarborClientState> {
|
||||
return parseHarborState(await request('/api/state'));
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user