Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { normalizeStoredState, type StoredState } from '../../shared/contracts/state.js';
|
||||
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
|
||||
|
||||
export const STATE_SCHEMA_VERSION = 4;
|
||||
|
||||
export interface AtomicWriteOptions {
|
||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||
mode?: number;
|
||||
}
|
||||
|
||||
interface RecoveryState {
|
||||
kind: 'corrupt-json';
|
||||
backupPath: string;
|
||||
recoveredAt: string;
|
||||
}
|
||||
|
||||
interface MigrationState {
|
||||
fromVersion: number;
|
||||
toVersion: unknown;
|
||||
backupPath: string;
|
||||
migratedAt: string;
|
||||
}
|
||||
|
||||
interface JsonStoreBaseOptions {
|
||||
filePath: string;
|
||||
initializeMissing?: boolean;
|
||||
backupWhen?: (before: unknown, after: unknown) => boolean;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export interface JsonStoreOptions<T> extends JsonStoreBaseOptions {
|
||||
defaultValue: T;
|
||||
migrate: (value: unknown) => T;
|
||||
}
|
||||
|
||||
export interface RawJsonStoreOptions extends JsonStoreBaseOptions {
|
||||
defaultValue: unknown;
|
||||
migrate?: never;
|
||||
}
|
||||
|
||||
export interface JsonStore<T> {
|
||||
read(): T;
|
||||
write(value: T, options?: AtomicWriteOptions): T;
|
||||
update(mutator: (value: T) => T): T;
|
||||
remove(): void;
|
||||
readonly recovery: RecoveryState | null;
|
||||
readonly migration: MigrationState | null;
|
||||
}
|
||||
|
||||
const clone = <T>(value: T): T => structuredClone(value);
|
||||
const stamp = (value: Date) => value.toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string) {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(directory, 'r');
|
||||
fs.fsyncSync(descriptor);
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(code)) throw error;
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function atomicWriteFile(
|
||||
filePath: string,
|
||||
contents: string | NodeJS.ArrayBufferView,
|
||||
{ beforeRename, mode }: AtomicWriteOptions = {},
|
||||
) {
|
||||
const directory = path.dirname(filePath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const temporaryPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
||||
);
|
||||
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
|
||||
let descriptor: number | undefined;
|
||||
|
||||
try {
|
||||
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
|
||||
fs.writeFileSync(descriptor, contents, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
beforeRename?.(temporaryPath, filePath);
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
syncDirectory(directory);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function atomicWriteJson(filePath: string, value: unknown, options?: AtomicWriteOptions) {
|
||||
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
|
||||
}
|
||||
|
||||
export function migrateStoredState(value: unknown): StoredState & { schemaVersion: number } {
|
||||
const stored = record(value);
|
||||
const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0;
|
||||
if (version < 0 || version > STATE_SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
|
||||
}
|
||||
const routeRules = version < 3
|
||||
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
|
||||
: stored.routeRules;
|
||||
return {
|
||||
...normalizeStoredState({ ...stored, routeRules }),
|
||||
schemaVersion: STATE_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
|
||||
export function createJsonStore(options: RawJsonStoreOptions): JsonStore<unknown>;
|
||||
export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStoreOptions): JsonStore<unknown> {
|
||||
const {
|
||||
filePath,
|
||||
defaultValue,
|
||||
initializeMissing = false,
|
||||
backupWhen = () => false,
|
||||
now = () => new Date(),
|
||||
} = options;
|
||||
const migrate = options.migrate || ((value: unknown) => value);
|
||||
let recovery: RecoveryState | null = null;
|
||||
let migration: MigrationState | null = null;
|
||||
|
||||
function write(value: unknown, writeOptions?: AtomicWriteOptions): unknown {
|
||||
const migrated = migrate(clone(value));
|
||||
atomicWriteJson(filePath, migrated, writeOptions);
|
||||
return clone(migrated);
|
||||
}
|
||||
|
||||
function read(): unknown {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const initial = migrate(clone(defaultValue));
|
||||
return initializeMissing ? write(initial) : clone(initial);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
|
||||
fs.renameSync(filePath, backupPath);
|
||||
try {
|
||||
const recovered = write(defaultValue);
|
||||
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
|
||||
return recovered;
|
||||
} catch (error) {
|
||||
fs.renameSync(backupPath, filePath);
|
||||
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const migrated = migrate(clone(parsed));
|
||||
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
|
||||
if (backupWhen(parsed, migrated)) {
|
||||
const parsedRecord = record(parsed);
|
||||
const fromVersion = Number.isSafeInteger(parsedRecord.schemaVersion) ? Number(parsedRecord.schemaVersion) : 0;
|
||||
const migratedRecord = record(migrated);
|
||||
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
|
||||
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
|
||||
migration = {
|
||||
fromVersion,
|
||||
toVersion: migratedRecord.schemaVersion,
|
||||
backupPath,
|
||||
migratedAt: now().toISOString(),
|
||||
};
|
||||
}
|
||||
atomicWriteJson(filePath, migrated);
|
||||
}
|
||||
return clone(migrated);
|
||||
}
|
||||
|
||||
function update(mutator: (value: unknown) => unknown): unknown {
|
||||
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
|
||||
const next = mutator(read());
|
||||
if (next && typeof next === 'object' && 'then' in next) {
|
||||
throw new TypeError('State store mutator must be synchronous');
|
||||
}
|
||||
return write(next);
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
update,
|
||||
remove: () => fs.rmSync(filePath, { force: true }),
|
||||
get recovery() { return recovery; },
|
||||
get migration() { return migration; },
|
||||
};
|
||||
}
|
||||
|
||||
export function createStateStore(
|
||||
filePath: string,
|
||||
options: Partial<Omit<JsonStoreOptions<StoredState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> = {},
|
||||
) {
|
||||
return createJsonStore<StoredState & { schemaVersion: number }>({
|
||||
filePath,
|
||||
defaultValue: migrateStoredState({}),
|
||||
migrate: migrateStoredState,
|
||||
initializeMissing: true,
|
||||
backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user