Persist operation state in server and reuse returned snapshots
This commit is contained in:
179
src/shared/contracts/state.js
Normal file
179
src/shared/contracts/state.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user