mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
refactor(pairing): share json state helpers
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
pruneExpiredPending,
|
||||
readJsonFile,
|
||||
resolvePairingPaths,
|
||||
writeJsonAtomic,
|
||||
} from "./pairing-files.js";
|
||||
|
||||
export type DevicePairingPendingRequest = {
|
||||
requestId: string;
|
||||
@@ -68,88 +72,27 @@ type DevicePairingStateFile = {
|
||||
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function resolvePaths(baseDir?: string) {
|
||||
const root = baseDir ?? resolveStateDir();
|
||||
const dir = path.join(root, "devices");
|
||||
return {
|
||||
dir,
|
||||
pendingPath: path.join(dir, "pending.json"),
|
||||
pairedPath: path.join(dir, "paired.json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJSON<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJSONAtomic(filePath: string, value: unknown) {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
|
||||
try {
|
||||
await fs.chmod(tmp, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await fs.rename(tmp, filePath);
|
||||
try {
|
||||
await fs.chmod(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpiredPending(
|
||||
pendingById: Record<string, DevicePairingPendingRequest>,
|
||||
nowMs: number,
|
||||
) {
|
||||
for (const [id, req] of Object.entries(pendingById)) {
|
||||
if (nowMs - req.ts > PENDING_TTL_MS) {
|
||||
delete pendingById[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lock: Promise<void> = Promise.resolve();
|
||||
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const prev = lock;
|
||||
let release: (() => void) | undefined;
|
||||
lock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
}
|
||||
const withLock = createAsyncLock();
|
||||
|
||||
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
|
||||
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices");
|
||||
const [pending, paired] = await Promise.all([
|
||||
readJSON<Record<string, DevicePairingPendingRequest>>(pendingPath),
|
||||
readJSON<Record<string, PairedDevice>>(pairedPath),
|
||||
readJsonFile<Record<string, DevicePairingPendingRequest>>(pendingPath),
|
||||
readJsonFile<Record<string, PairedDevice>>(pairedPath),
|
||||
]);
|
||||
const state: DevicePairingStateFile = {
|
||||
pendingById: pending ?? {},
|
||||
pairedByDeviceId: paired ?? {},
|
||||
};
|
||||
pruneExpiredPending(state.pendingById, Date.now());
|
||||
pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function persistState(state: DevicePairingStateFile, baseDir?: string) {
|
||||
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices");
|
||||
await Promise.all([
|
||||
writeJSONAtomic(pendingPath, state.pendingById),
|
||||
writeJSONAtomic(pairedPath, state.pairedByDeviceId),
|
||||
writeJsonAtomic(pendingPath, state.pendingById),
|
||||
writeJsonAtomic(pairedPath, state.pairedByDeviceId),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
pruneExpiredPending,
|
||||
readJsonFile,
|
||||
resolvePairingPaths,
|
||||
writeJsonAtomic,
|
||||
} from "./pairing-files.js";
|
||||
|
||||
export type NodePairingPendingRequest = {
|
||||
requestId: string;
|
||||
@@ -54,88 +58,27 @@ type NodePairingStateFile = {
|
||||
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function resolvePaths(baseDir?: string) {
|
||||
const root = baseDir ?? resolveStateDir();
|
||||
const dir = path.join(root, "nodes");
|
||||
return {
|
||||
dir,
|
||||
pendingPath: path.join(dir, "pending.json"),
|
||||
pairedPath: path.join(dir, "paired.json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJSON<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJSONAtomic(filePath: string, value: unknown) {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
|
||||
try {
|
||||
await fs.chmod(tmp, 0o600);
|
||||
} catch {
|
||||
// best-effort; ignore on platforms without chmod
|
||||
}
|
||||
await fs.rename(tmp, filePath);
|
||||
try {
|
||||
await fs.chmod(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort; ignore on platforms without chmod
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpiredPending(
|
||||
pendingById: Record<string, NodePairingPendingRequest>,
|
||||
nowMs: number,
|
||||
) {
|
||||
for (const [id, req] of Object.entries(pendingById)) {
|
||||
if (nowMs - req.ts > PENDING_TTL_MS) {
|
||||
delete pendingById[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lock: Promise<void> = Promise.resolve();
|
||||
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const prev = lock;
|
||||
let release: (() => void) | undefined;
|
||||
lock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
}
|
||||
const withLock = createAsyncLock();
|
||||
|
||||
async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
|
||||
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes");
|
||||
const [pending, paired] = await Promise.all([
|
||||
readJSON<Record<string, NodePairingPendingRequest>>(pendingPath),
|
||||
readJSON<Record<string, NodePairingPairedNode>>(pairedPath),
|
||||
readJsonFile<Record<string, NodePairingPendingRequest>>(pendingPath),
|
||||
readJsonFile<Record<string, NodePairingPairedNode>>(pairedPath),
|
||||
]);
|
||||
const state: NodePairingStateFile = {
|
||||
pendingById: pending ?? {},
|
||||
pairedByNodeId: paired ?? {},
|
||||
};
|
||||
pruneExpiredPending(state.pendingById, Date.now());
|
||||
pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function persistState(state: NodePairingStateFile, baseDir?: string) {
|
||||
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes");
|
||||
await Promise.all([
|
||||
writeJSONAtomic(pendingPath, state.pendingById),
|
||||
writeJSONAtomic(pairedPath, state.pairedByNodeId),
|
||||
writeJsonAtomic(pendingPath, state.pendingById),
|
||||
writeJsonAtomic(pairedPath, state.pairedByNodeId),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
70
src/infra/pairing-files.ts
Normal file
70
src/infra/pairing-files.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
|
||||
export function resolvePairingPaths(baseDir: string | undefined, subdir: string) {
|
||||
const root = baseDir ?? resolveStateDir();
|
||||
const dir = path.join(root, subdir);
|
||||
return {
|
||||
dir,
|
||||
pendingPath: path.join(dir, "pending.json"),
|
||||
pairedPath: path.join(dir, "paired.json"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonAtomic(filePath: string, value: unknown) {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
|
||||
try {
|
||||
await fs.chmod(tmp, 0o600);
|
||||
} catch {
|
||||
// best-effort; ignore on platforms without chmod
|
||||
}
|
||||
await fs.rename(tmp, filePath);
|
||||
try {
|
||||
await fs.chmod(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort; ignore on platforms without chmod
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneExpiredPending<T extends { ts: number }>(
|
||||
pendingById: Record<string, T>,
|
||||
nowMs: number,
|
||||
ttlMs: number,
|
||||
) {
|
||||
for (const [id, req] of Object.entries(pendingById)) {
|
||||
if (nowMs - req.ts > ttlMs) {
|
||||
delete pendingById[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createAsyncLock() {
|
||||
let lock: Promise<void> = Promise.resolve();
|
||||
return async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const prev = lock;
|
||||
let release: (() => void) | undefined;
|
||||
lock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user