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,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;
}