Persist operation state in server and reuse returned snapshots
This commit is contained in:
@@ -22,6 +22,11 @@ import {
|
||||
writeSingboxConfig,
|
||||
} from './singbox.js';
|
||||
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js';
|
||||
import {
|
||||
createStateSnapshot,
|
||||
normalizeStoredState,
|
||||
withStateV0Compatibility,
|
||||
} from '../shared/contracts/state.js';
|
||||
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
@@ -43,6 +48,8 @@ let gatewayDiscoveryPromise = null;
|
||||
let gatewayDiscoveryTimer = null;
|
||||
let gatewayAutoState = createGatewayAutoState();
|
||||
let controlOperation = Promise.resolve();
|
||||
let operationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
||||
let revision = 0;
|
||||
|
||||
function readJson(filePath, fallback) {
|
||||
try {
|
||||
@@ -59,6 +66,41 @@ function writeJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
revision = normalizeStoredState(readJson(settings.statePath, {})).revision;
|
||||
|
||||
function updateStoredState(update) {
|
||||
const current = normalizeStoredState(readJson(settings.statePath, {}));
|
||||
const next = normalizeStoredState(update(current));
|
||||
revision = Math.max(revision, current.revision) + 1;
|
||||
next.revision = revision;
|
||||
writeJson(settings.statePath, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function withOperation(kind, operation) {
|
||||
operationState = {
|
||||
kind,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
};
|
||||
updateStoredState((state) => state);
|
||||
try {
|
||||
const result = await operation();
|
||||
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
||||
updateStoredState((state) => state);
|
||||
return result;
|
||||
} catch (error) {
|
||||
operationState = {
|
||||
...operationState,
|
||||
status: 'failed',
|
||||
error: error?.message || String(error),
|
||||
};
|
||||
updateStoredState((state) => state);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeControl(operation) {
|
||||
const result = controlOperation.then(operation, operation);
|
||||
controlOperation = result.catch(() => {});
|
||||
@@ -120,35 +162,26 @@ const stopSingbox = () => singboxRuntime.stop();
|
||||
const startSingbox = () => singboxRuntime.apply();
|
||||
|
||||
async function publicState() {
|
||||
await singboxRuntime.refresh();
|
||||
const state = readJson(settings.statePath, {});
|
||||
const runtime = await singboxRuntime.refresh();
|
||||
const state = normalizeStoredState(readJson(settings.statePath, {}));
|
||||
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
|
||||
return {
|
||||
mode: settings.appMode,
|
||||
const configExists = fs.existsSync(settings.configPath);
|
||||
const snapshot = createStateSnapshot({
|
||||
storedState: state,
|
||||
runtime,
|
||||
gatewayAuto: gatewayAutoState,
|
||||
appMode: settings.appMode,
|
||||
configExists,
|
||||
subscriptionHost: subscriptionHost(state.subscriptionUrl),
|
||||
operation: operationState,
|
||||
});
|
||||
return withStateV0Compatibility(snapshot, {
|
||||
storedState: { ...state, gatewayAutoEnabled },
|
||||
gatewayAuto: gatewayAutoState,
|
||||
port: settings.port,
|
||||
proxyPort: settings.proxyPort,
|
||||
configExists: fs.existsSync(settings.configPath),
|
||||
singboxRunning: singboxRuntime.running,
|
||||
singboxStartedAt: singboxRuntime.startedAt,
|
||||
subscriptionHost: subscriptionHost(state.subscriptionUrl),
|
||||
hasSubscription: Boolean(state.subscriptionUrl),
|
||||
selectedTag: state.selectedTag || '',
|
||||
userInfo: state.userInfo || {},
|
||||
fetchedAt: state.fetchedAt || null,
|
||||
gatewayAuto: settings.appMode === 'client' ? {
|
||||
mode: gatewayAutoState.mode,
|
||||
enabled: gatewayAutoEnabled,
|
||||
available: Boolean(gatewayAutoState.gatewayId),
|
||||
address: gatewayAutoState.gateway?.gateway || '',
|
||||
interface: gatewayAutoState.gateway?.interface || '',
|
||||
failures: gatewayAutoState.failures,
|
||||
lastError: gatewayAutoState.lastError,
|
||||
} : null,
|
||||
servers: (state.servers || []).map((server) => ({
|
||||
...server,
|
||||
tag: String(server.tag || '').trim(),
|
||||
})),
|
||||
};
|
||||
configExists,
|
||||
});
|
||||
}
|
||||
|
||||
function writeCurrentConfig() {
|
||||
@@ -161,9 +194,13 @@ function writeCurrentConfig() {
|
||||
|
||||
async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
|
||||
const previousState = gatewayAutoState;
|
||||
const stateChanged = !isDeepStrictEqual(previousState, nextState);
|
||||
const modeChanged = previousState.mode !== nextState.mode;
|
||||
gatewayAutoState = nextState;
|
||||
if (!modeChanged) return;
|
||||
if (!modeChanged) {
|
||||
if (stateChanged) updateStoredState((state) => state);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
@@ -179,6 +216,8 @@ async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (stateChanged) updateStoredState((state) => state);
|
||||
|
||||
const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : '';
|
||||
console.log(`[control] client route: ${nextState.mode}${route}`);
|
||||
}
|
||||
@@ -271,10 +310,18 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
return gatewayDiscoveryPromise;
|
||||
}
|
||||
|
||||
async function applySelectedServer(selectedTag) {
|
||||
async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!cached?.config) throw new Error('Сначала загрузите подписку');
|
||||
|
||||
if (persist) {
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
selectedTag,
|
||||
connectionDesired: 'running',
|
||||
}));
|
||||
}
|
||||
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
@@ -286,11 +333,13 @@ async function applySelectedServer(selectedTag) {
|
||||
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||
throw error;
|
||||
}
|
||||
writeJson(settings.statePath, {
|
||||
...readJson(settings.statePath, {}),
|
||||
selectedTag,
|
||||
appliedAt: new Date().toISOString(),
|
||||
});
|
||||
if (persist) {
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
appliedTag: selectedTag,
|
||||
appliedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSavedSubscription() {
|
||||
@@ -326,7 +375,9 @@ function refreshSavedSubscription() {
|
||||
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
|
||||
|
||||
try {
|
||||
if (singboxRuntime.running && activeConfigChanged) await applySelectedServer(selectedTag);
|
||||
if (singboxRuntime.running && activeConfigChanged) {
|
||||
await applySelectedServer(selectedTag, { persist: false });
|
||||
}
|
||||
else if (selectedTag) {
|
||||
if (!singboxRuntime.running) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
|
||||
} else {
|
||||
@@ -340,14 +391,15 @@ function refreshSavedSubscription() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
writeJson(settings.statePath, {
|
||||
...readJson(settings.statePath, {}),
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
subscriptionUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
});
|
||||
appliedTag: selectedTag,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -364,6 +416,10 @@ function refreshSavedSubscription() {
|
||||
return subscriptionRefreshPromise;
|
||||
}
|
||||
|
||||
async function sendState(res, extra = {}) {
|
||||
return sendJson(res, 200, { success: true, ...extra, state: await publicState() });
|
||||
}
|
||||
|
||||
async function handleApi(req, res) {
|
||||
if (req.method === 'GET' && req.url === '/api/state') {
|
||||
return sendJson(res, 200, await publicState());
|
||||
@@ -397,38 +453,47 @@ async function handleApi(req, res) {
|
||||
...await tcpPing(server.server, server.server_port),
|
||||
checkedAt: new Date().toISOString(),
|
||||
})));
|
||||
return sendJson(res, 200, { success: true, results });
|
||||
return sendState(res, { results });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||
const { url = '' } = await readBody(req);
|
||||
const normalizedUrl = String(url).trim();
|
||||
const parsed = await fetchSubscription(normalizedUrl);
|
||||
await serializeControl(async () => {
|
||||
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
|
||||
const previousState = readJson(settings.statePath, {});
|
||||
writeJson(settings.statePath, {
|
||||
subscriptionUrl: normalizedUrl,
|
||||
gatewayAutoEnabled: previousState.gatewayAutoEnabled !== false,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
const parsed = await withOperation('subscription-import', async () => {
|
||||
const result = await fetchSubscription(normalizedUrl);
|
||||
await serializeControl(async () => {
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...result });
|
||||
updateStoredState((state) => ({
|
||||
subscriptionUrl: normalizedUrl,
|
||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||
servers: result.servers,
|
||||
userInfo: result.userInfo,
|
||||
fetchedAt: result.fetchedAt,
|
||||
selectedTag: '',
|
||||
appliedTag: '',
|
||||
connectionDesired: 'stopped',
|
||||
}));
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
});
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
return result;
|
||||
});
|
||||
return sendJson(res, 200, { success: true, ...parsed });
|
||||
return sendState(res, parsed);
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/validate') {
|
||||
const { url = '' } = await readBody(req);
|
||||
const parsed = await fetchSubscription(String(url).trim());
|
||||
return sendJson(res, 200, { success: true, servers: parsed.servers.length });
|
||||
return sendState(res, { servers: parsed.servers.length });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
||||
return sendJson(res, 200, await refreshSavedSubscription());
|
||||
const { success, ...result } = await withOperation(
|
||||
'subscription-refresh',
|
||||
() => refreshSavedSubscription(),
|
||||
);
|
||||
return sendState(res, result);
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
|
||||
@@ -439,50 +504,59 @@ async function handleApi(req, res) {
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' });
|
||||
}
|
||||
await serializeControl(async () => {
|
||||
writeJson(settings.statePath, {
|
||||
...readJson(settings.statePath, {}),
|
||||
gatewayAutoEnabled: enabled,
|
||||
});
|
||||
await withOperation('gateway-auto', () => serializeControl(async () => {
|
||||
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
|
||||
});
|
||||
return sendJson(res, 200, { success: true, gatewayAuto: (await publicState()).gatewayAuto });
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
gatewayAutoEnabled: enabled,
|
||||
}));
|
||||
}));
|
||||
const state = await publicState();
|
||||
return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||
await serializeControl(async () => {
|
||||
await withOperation('subscription-forget', () => serializeControl(async () => {
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||
writeJson(settings.statePath, {});
|
||||
updateStoredState(() => ({}));
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
});
|
||||
return sendJson(res, 200, { success: true });
|
||||
}));
|
||||
return sendState(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: 'Выберите сервер' });
|
||||
await serializeControl(() => applySelectedServer(tag));
|
||||
return sendJson(res, 200, { success: true, selectedTag: tag });
|
||||
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag)));
|
||||
return sendState(res, { selectedTag: tag });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||
await serializeControl(() => stopSingbox());
|
||||
return sendJson(res, 200, { success: true, singboxRunning: false });
|
||||
await withOperation('stop', () => serializeControl(async () => {
|
||||
await stopSingbox();
|
||||
updateStoredState((state) => ({ ...state, connectionDesired: 'stopped' }));
|
||||
}));
|
||||
return sendState(res, { singboxRunning: false });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||
await serializeControl(async () => {
|
||||
await withOperation('start', () => serializeControl(async () => {
|
||||
if (!fs.existsSync(settings.configPath)) {
|
||||
const error = new Error('Сначала выберите сервер');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
await singboxRuntime.restart();
|
||||
});
|
||||
return sendJson(res, 200, { success: true, singboxRunning: true });
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
appliedTag: state.selectedTag,
|
||||
connectionDesired: 'running',
|
||||
}));
|
||||
}));
|
||||
return sendState(res, { singboxRunning: true });
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { success: false, error: 'Не найдено' });
|
||||
|
||||
Reference in New Issue
Block a user