Persist operation state in server and reuse returned snapshots
Some checks failed
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Failing after 1s

This commit is contained in:
2026-07-11 18:59:14 +03:00
parent b0b9da51b6
commit e81a48a5b1
5 changed files with 585 additions and 90 deletions

View File

@@ -0,0 +1,63 @@
# Harbor application state v1
`GET /api/state` is the canonical Harbor domain snapshot. Successful POST and DELETE endpoints return the same snapshot as `state` while retaining their v0 response fields for compatibility.
```json
{
"apiVersion": 1,
"revision": 42,
"generatedAt": "2026-07-11T15:00:00.000Z",
"mode": "client",
"subscription": {
"status": "ready",
"host": "provider.example/…",
"fetchedAt": "2026-07-11T14:58:00.000Z",
"userInfo": {}
},
"selection": {
"desiredServerId": "Amsterdam",
"appliedServerId": "Amsterdam"
},
"connection": {
"desired": "running",
"process": "running",
"startedAt": "2026-07-11T14:59:10.000Z",
"lastError": null
},
"route": {
"mode": "local-vpn",
"gatewayAddress": null,
"lastVerifiedAt": null,
"reason": "auto"
},
"operation": {
"kind": null,
"status": "idle",
"startedAt": null,
"error": null
},
"servers": []
}
```
The backend owns subscription metadata, servers, desired/applied selection, desired/process connection state, route and current operation. React may keep only unsaved form values, pending selection and visual state. Browser transport freshness is not part of this contract.
## Revision rules
`revision` is persisted in the existing `state.json` and increases on externally visible transitions, including operation start/completion/failure, import, refresh, forget, apply, start, stop and Gateway Auto changes. `generatedAt` is response metadata and does not change revision by itself.
A consumer must eventually apply only snapshots whose revision is at least its current revision. The frontend comparison and stale/offline transport envelope are intentionally handled by TASK-002 and TASK-003.
## Desired and applied state
`selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded.
Server IDs are currently derived from the existing trimmed subscription tag. Stable IDs across cosmetic renames are deferred to TASK-008.
## Compatibility and migration
No path, volume or file is renamed. A legacy `state.json` without `revision`, `appliedTag` or `connectionDesired` is normalized on read: revision starts at `0`, the legacy `selectedTag` is treated as both desired and applied, and connection intent is inferred from the existing config. The next domain write stores the added fields. Existing unknown fields remain untouched.
During the v0 compatibility window, the snapshot also exposes `selectedTag`, `singboxRunning`, `servers[].tag`, `gatewayAuto` and the other previous GET fields. Mutation responses retain their previous result fields and add `state`. The canonical `subscription` object never contains the full subscription URL.
Rollback is code-only: deploy the previous build. The added persisted fields are ignored by the previous implementation, so no data rewrite is needed. Revisions created by the newer build remain harmless integers in `state.json`.

View File

@@ -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,
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);
const parsed = await withOperation('subscription-import', async () => {
const result = 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,
});
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();
});
return sendJson(res, 200, { success: true, ...parsed });
return result;
});
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: 'Не найдено' });

View File

@@ -0,0 +1,179 @@
const MODES = new Set(['client', 'gateway']);
const CONNECTION_STATES = new Set(['running', 'stopped']);
const OPERATION_STATES = new Set(['idle', 'running', 'failed']);
const text = (value) => String(value || '').trim();
const nullableText = (value) => value == null ? null : String(value);
const dateOrNull = (value) => (
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
);
export function normalizeStoredState(value) {
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const selectedTag = text(state.selectedTag);
return {
...state,
revision: Number.isSafeInteger(state.revision) && state.revision >= 0 ? state.revision : 0,
selectedTag,
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
servers: Array.isArray(state.servers) ? state.servers : [],
};
}
export function createStateSnapshot({
storedState,
runtime,
gatewayAuto,
appMode,
configExists,
subscriptionHost,
operation = { kind: null, status: 'idle', startedAt: null, error: null },
now = new Date(),
}) {
const stored = normalizeStoredState(storedState);
const mode = MODES.has(appMode) ? appMode : 'gateway';
const hasSubscription = Boolean(stored.subscriptionUrl);
const desired = CONNECTION_STATES.has(stored.connectionDesired)
? stored.connectionDesired
: configExists ? 'running' : 'stopped';
const servers = stored.servers.map((server) => {
const tag = text(server.tag);
return {
...server,
id: tag,
label: tag,
host: text(server.server),
port: Number(server.server_port) || 0,
protocol: text(server.type),
// v0 compatibility: the current UI and integrations still read these aliases.
tag,
server: text(server.server),
server_port: Number(server.server_port) || 0,
type: text(server.type),
};
});
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
return assertStateSnapshot({
apiVersion: 1,
revision: stored.revision,
generatedAt: now.toISOString(),
mode,
subscription: {
status: hasSubscription ? 'ready' : 'missing',
host: hasSubscription ? subscriptionHost : '',
fetchedAt: dateOrNull(stored.fetchedAt),
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
},
selection: {
desiredServerId: stored.selectedTag,
appliedServerId: stored.appliedTag,
},
connection: {
desired,
process: runtime?.running ? 'running' : 'stopped',
startedAt: dateOrNull(runtime?.startedAt),
lastError: null,
},
route: {
mode: routeMode,
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
lastVerifiedAt: null,
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
},
operation: {
kind: nullableText(operation.kind),
status: operation.status,
startedAt: nullableText(operation.startedAt),
error: nullableText(operation.error),
},
servers,
});
}
export function withStateV0Compatibility(snapshot, {
storedState,
gatewayAuto,
port,
proxyPort,
configExists,
}) {
const stored = normalizeStoredState(storedState);
return {
...snapshot,
port,
proxyPort,
configExists,
singboxRunning: snapshot.connection.process === 'running',
singboxStartedAt: snapshot.connection.startedAt,
subscriptionHost: snapshot.subscription.host,
hasSubscription: snapshot.subscription.status === 'ready',
selectedTag: snapshot.selection.desiredServerId,
userInfo: snapshot.subscription.userInfo,
fetchedAt: snapshot.subscription.fetchedAt,
gatewayAuto: snapshot.mode === 'client' ? {
mode: gatewayAuto?.mode || 'local-vpn',
enabled: stored.gatewayAutoEnabled !== false,
available: Boolean(gatewayAuto?.gatewayId),
address: gatewayAuto?.gateway?.gateway || '',
interface: gatewayAuto?.gateway?.interface || '',
failures: Number(gatewayAuto?.failures) || 0,
lastError: gatewayAuto?.lastError || '',
} : null,
};
}
export function assertStateSnapshot(snapshot) {
const validDate = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value));
const nullableDate = (value) => value === null || validDate(value);
const nullableString = (value) => value === null || typeof value === 'string';
const validServer = (server) => (
server &&
typeof server.id === 'string' &&
typeof server.label === 'string' &&
typeof server.host === 'string' &&
Number.isInteger(server.port) &&
server.port >= 0 &&
typeof server.protocol === 'string'
);
if (
!snapshot ||
snapshot.apiVersion !== 1 ||
!Number.isSafeInteger(snapshot.revision) ||
snapshot.revision < 0 ||
!validDate(snapshot.generatedAt) ||
!MODES.has(snapshot.mode) ||
!snapshot.subscription ||
!['missing', 'ready'].includes(snapshot.subscription.status) ||
typeof snapshot.subscription.host !== 'string' ||
Object.hasOwn(snapshot.subscription, 'url') ||
!nullableDate(snapshot.subscription.fetchedAt) ||
!snapshot.subscription.userInfo ||
typeof snapshot.subscription.userInfo !== 'object' ||
!snapshot.selection ||
typeof snapshot.selection.desiredServerId !== 'string' ||
typeof snapshot.selection.appliedServerId !== 'string' ||
!snapshot.connection ||
!CONNECTION_STATES.has(snapshot.connection.desired) ||
!CONNECTION_STATES.has(snapshot.connection.process) ||
!nullableDate(snapshot.connection.startedAt) ||
!nullableString(snapshot.connection.lastError) ||
!snapshot.route ||
typeof snapshot.route.mode !== 'string' ||
!nullableString(snapshot.route.gatewayAddress) ||
!nullableDate(snapshot.route.lastVerifiedAt) ||
typeof snapshot.route.reason !== 'string' ||
!snapshot.operation ||
!nullableString(snapshot.operation.kind) ||
!OPERATION_STATES.has(snapshot.operation.status) ||
!nullableDate(snapshot.operation.startedAt) ||
!nullableString(snapshot.operation.error) ||
!Array.isArray(snapshot.servers) ||
!snapshot.servers.every(validServer)
) {
throw new TypeError('Invalid Harbor state snapshot v1');
}
return snapshot;
}

View File

@@ -8,15 +8,12 @@ function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [state, setState] = useState(null);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [servers, setServers] = useState([]);
const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function loadState() {
const data = await api.state();
function syncState(data) {
setState(data);
setServers(data.servers || []);
setPendingTag((current) => (
(data.servers || []).some((server) => server.tag === current)
? current
@@ -24,6 +21,10 @@ function App() {
));
}
async function loadState() {
syncState(await api.state());
}
useEffect(() => {
loadState().catch((err) => setError(err.message));
const timer = setInterval(() => loadState().catch(() => {}), 5000);
@@ -43,8 +44,10 @@ function App() {
setBusy(true);
setError('');
try {
await action();
await loadState();
const result = await action();
if (result?.state) syncState(result.state);
else await loadState();
return result;
} catch (err) {
setError(err.message);
throw err;
@@ -56,31 +59,24 @@ function App() {
async function fetchSubscription() {
return run(async () => {
const data = await api.subscription.fetch(subscriptionUrl);
setServers(data.servers || []);
setPendingTag('');
return data;
});
}
async function refreshSubscription() {
const data = await api.subscription.refresh();
setState((current) => current ? {
...current,
userInfo: data.userInfo,
fetchedAt: data.fetchedAt,
servers: data.servers,
selectedTag: data.selectedTag,
} : current);
setServers(data.servers || []);
syncState(data.state || data);
setPendingTag(data.selectedTag || '');
return data;
}
async function forgetSubscription() {
return run(async () => {
await api.subscription.forget();
const data = await api.subscription.forget();
setSubscriptionUrl('');
setServers([]);
setPendingTag('');
return data;
});
}
@@ -96,7 +92,7 @@ function App() {
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : servers}
servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : state.servers || []}
pendingTag={previewReady ? 'Amsterdam' : pendingTag}
setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription}

View File

@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
assertStateSnapshot,
createStateSnapshot,
normalizeStoredState,
} from '../../src/shared/contracts/state.js';
const root = path.resolve(import.meta.dirname, '../..');
function listen(server) {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
}
function close(server) {
return new Promise((resolve) => server.close(resolve));
}
async function freePort() {
const server = http.createServer();
const port = await listen(server);
await close(server);
return port;
}
async function request(port, pathname, method = 'GET', body) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
method,
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json();
assert.equal(response.ok, true, JSON.stringify(payload));
return payload;
}
async function waitForState(port, child, stderr) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) throw new Error(`Harbor exited early: ${stderr()}`);
try {
return await request(port, '/api/state');
} catch {
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
throw new Error(`Harbor did not start: ${stderr()}`);
}
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
const stored = normalizeStoredState({
subscriptionUrl: 'https://provider.example/subscription/full-secret-token',
selectedTag: ' legacy ',
servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }],
});
const snapshot = createStateSnapshot({
storedState: stored,
runtime: { running: true, startedAt: '2026-07-11T10:00:00.000Z' },
gatewayAuto: null,
appMode: 'gateway',
configExists: true,
subscriptionHost: 'provider.example/…',
now: new Date('2026-07-11T12:00:00.000Z'),
});
assert.equal(snapshot.apiVersion, 1);
assert.deepEqual(snapshot.selection, {
desiredServerId: 'legacy',
appliedServerId: 'legacy',
});
assert.equal(snapshot.connection.process, 'running');
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
assert.throws(
() => assertStateSnapshot({ ...snapshot, revision: -1 }),
/Invalid Harbor state snapshot v1/,
);
});
test('GET and domain mutations return one state shape with monotonic revisions', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
const binDir = path.join(dir, 'bin');
const config = {
outbounds: [{
type: 'vless',
tag: 'test-vpn',
server: 'vpn.example.test',
server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000',
tls: { enabled: true },
}],
};
fs.mkdirSync(binDir);
fs.writeFileSync(path.join(binDir, 'sing-box'), `#!/usr/bin/env node
if (process.argv[2] === 'check') process.exit(0);
process.on('SIGTERM', () => process.exit(0));
setInterval(() => {}, 60_000);
`);
fs.chmodSync(path.join(binDir, 'sing-box'), 0o755);
const subscriptionServer = http.createServer((req, res) => {
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100',
});
res.end(JSON.stringify(config));
});
const subscriptionPort = await listen(subscriptionServer);
const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/full-secret-token`;
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
subscriptionUrl,
selectedTag: 'test-vpn',
servers: [{ tag: 'test-vpn', type: 'vless', server: 'vpn.example.test', server_port: 443 }],
}));
fs.writeFileSync(path.join(dir, 'subscription-cache.json'), JSON.stringify({
url: subscriptionUrl,
config,
}));
const port = await freePort();
const child = spawn(process.execPath, ['src/server/index.js'], {
cwd: root,
env: {
...process.env,
APP_MODE: 'client',
DATA_DIR: dir,
PORT: String(port),
PATH: `${binDir}:${process.env.PATH}`,
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
},
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; });
t.after(async () => {
child.kill('SIGTERM');
if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve));
await close(subscriptionServer);
fs.rmSync(dir, { recursive: true, force: true });
});
const initial = await waitForState(port, child, () => stderr);
assertStateSnapshot(initial);
assert.equal(initial.selection.appliedServerId, 'test-vpn');
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const stateKeys = Object.keys(initial).sort();
let revision = initial.revision;
async function stateResponse(pathname, method = 'POST', body) {
const result = await request(port, pathname, method, body);
assert.deepEqual(Object.keys(result.state).sort(), stateKeys);
assertStateSnapshot(result.state);
return result;
}
async function mutation(pathname, method = 'POST', body) {
const result = await stateResponse(pathname, method, body);
assert.ok(result.state.revision > revision, `${pathname} did not increase revision`);
revision = result.state.revision;
return result;
}
await stateResponse('/api/subscription/validate', 'POST', { url: subscriptionUrl });
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' });
assert.deepEqual(applied.state.selection, {
desiredServerId: 'test-vpn',
appliedServerId: 'test-vpn',
});
assert.equal(applied.state.connection.process, 'running');
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
await mutation('/api/subscription/refresh');
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing');
assert.equal(forgotten.state.servers.length, 0);
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
});