Refactor VPN proxy client implementation
Build and Deploy Gateway / build-and-push (push) Failing after 2s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
+203
View File
@@ -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'));
},
};