refactor(pairing): share json state helpers

This commit is contained in:
Peter Steinberger
2026-02-14 15:13:28 +00:00
parent e9de242159
commit cc233da373
3 changed files with 100 additions and 144 deletions

View File

@@ -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),
]);
}

View File

@@ -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),
]);
}

View 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?.();
}
};
}