Unify Harbor error handling across server and client
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
normalizeStoredState,
|
||||
withStateV0Compatibility,
|
||||
} from '../shared/contracts/state.js';
|
||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
@@ -91,10 +93,11 @@ async function withOperation(kind, operation) {
|
||||
updateStoredState((state) => state);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const harborError = normalizeHarborError(error);
|
||||
operationState = {
|
||||
...operationState,
|
||||
status: 'failed',
|
||||
error: error?.message || String(error),
|
||||
error: harborError.message,
|
||||
};
|
||||
updateStoredState((state) => state);
|
||||
throw error;
|
||||
@@ -103,7 +106,8 @@ async function withOperation(kind, operation) {
|
||||
|
||||
function serializeControl(operation) {
|
||||
const result = controlOperation.then(operation, operation);
|
||||
controlOperation = result.catch(() => {});
|
||||
// The caller observes result; this settled tail only keeps the next operation runnable.
|
||||
controlOperation = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -112,6 +116,29 @@ function sendJson(res, statusCode, payload) {
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function redactLogDetails(value) {
|
||||
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
|
||||
}
|
||||
|
||||
function sendError(res, error) {
|
||||
const harborError = normalizeHarborError(error);
|
||||
const correlationId = crypto.randomUUID();
|
||||
const technical = harborError.cause?.message || harborError.details || error;
|
||||
console.error(
|
||||
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technical?.message || technical)}`,
|
||||
);
|
||||
return sendJson(res, harborError.status, {
|
||||
success: false,
|
||||
error: {
|
||||
code: harborError.code,
|
||||
message: harborError.message,
|
||||
retryable: harborError.retryable,
|
||||
correlationId,
|
||||
...(harborError.details ? { details: harborError.details } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
@@ -122,9 +149,7 @@ function readBody(req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
tooLarge = true;
|
||||
const error = new Error('Тело запроса слишком большое');
|
||||
error.statusCode = 413;
|
||||
reject(error);
|
||||
reject(new HarborError('REQUEST_INVALID'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
@@ -134,10 +159,8 @@ function readBody(req) {
|
||||
if (!chunks.length) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch {
|
||||
const error = new Error('Невалидный JSON в теле запроса');
|
||||
error.statusCode = 400;
|
||||
reject(error);
|
||||
} catch (cause) {
|
||||
reject(new HarborError('REQUEST_INVALID', { cause }));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
@@ -312,7 +335,8 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
|
||||
async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!cached?.config) throw new Error('Сначала загрузите подписку');
|
||||
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
|
||||
const nextConfig = buildActiveConfig(cached.config, selectedTag);
|
||||
|
||||
if (persist) {
|
||||
updateStoredState((state) => ({
|
||||
@@ -325,7 +349,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, selectedTag));
|
||||
writeSingboxConfig(nextConfig);
|
||||
try {
|
||||
await startSingbox();
|
||||
} catch (error) {
|
||||
@@ -348,9 +372,7 @@ function refreshSavedSubscription() {
|
||||
subscriptionRefreshPromise = (async () => {
|
||||
const initialState = readJson(settings.statePath, {});
|
||||
if (!initialState.subscriptionUrl) {
|
||||
const error = new Error('Подписка не настроена');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
const subscriptionUrl = initialState.subscriptionUrl;
|
||||
@@ -358,7 +380,7 @@ function refreshSavedSubscription() {
|
||||
return serializeControl(async () => {
|
||||
const currentState = readJson(settings.statePath, {});
|
||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new Error('Подписка была изменена во время обновления');
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
|
||||
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
|
||||
@@ -498,11 +520,11 @@ async function handleApi(req, res) {
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
|
||||
if (settings.appMode !== 'client') {
|
||||
return sendJson(res, 400, { success: false, error: 'Режим доступен только в Harbor Connect' });
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const { enabled } = await readBody(req);
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' });
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
await withOperation('gateway-auto', () => serializeControl(async () => {
|
||||
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
|
||||
@@ -529,7 +551,7 @@ async function handleApi(req, res) {
|
||||
if (req.method === 'POST' && req.url === '/api/apply') {
|
||||
const { selectedTag = '' } = await readBody(req);
|
||||
const tag = String(selectedTag).trim();
|
||||
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' });
|
||||
if (!tag) throw new HarborError('REQUEST_INVALID');
|
||||
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag)));
|
||||
return sendState(res, { selectedTag: tag });
|
||||
}
|
||||
@@ -545,9 +567,7 @@ async function handleApi(req, res) {
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||
await withOperation('start', () => serializeControl(async () => {
|
||||
if (!fs.existsSync(settings.configPath)) {
|
||||
const error = new Error('Сначала выберите сервер');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
throw new HarborError('CONFIG_INVALID');
|
||||
}
|
||||
await singboxRuntime.restart();
|
||||
updateStoredState((state) => ({
|
||||
@@ -559,7 +579,7 @@ async function handleApi(req, res) {
|
||||
return sendState(res, { singboxRunning: true });
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { success: false, error: 'Не найдено' });
|
||||
return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
|
||||
}
|
||||
|
||||
const mime = {
|
||||
@@ -592,11 +612,7 @@ const server = http.createServer(async (req, res) => {
|
||||
? await handleApi(req, res)
|
||||
: serveStatic(req, res);
|
||||
} catch (error) {
|
||||
console.error('[control] request failed', error);
|
||||
return sendJson(res, error.statusCode || 500, {
|
||||
success: false,
|
||||
error: error.message || String(error),
|
||||
});
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user