refactor(infra): extract json file + async lock helpers

This commit is contained in:
Peter Steinberger
2026-02-15 21:45:38 +00:00
parent ff4f59ec90
commit c9bb6bd0d8
3 changed files with 58 additions and 81 deletions

52
src/infra/json-files.ts Normal file
View File

@@ -0,0 +1,52 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
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,
options?: { mode?: number },
) {
const mode = options?.mode ?? 0o600;
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, mode);
} catch {
// best-effort; ignore on platforms without chmod
}
await fs.rename(tmp, filePath);
try {
await fs.chmod(filePath, mode);
} catch {
// best-effort; ignore on platforms without chmod
}
}
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?.();
}
};
}

View File

@@ -1,8 +1,8 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
export { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
export function resolvePairingPaths(baseDir: string | undefined, subdir: string) {
const root = baseDir ?? resolveStateDir();
const dir = path.join(root, subdir);
@@ -13,33 +13,6 @@ export function resolvePairingPaths(baseDir: string | undefined, subdir: string)
};
}
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,
@@ -51,20 +24,3 @@ export function pruneExpiredPending<T extends { ts: number }>(
}
}
}
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?.();
}
};
}

View File

@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
export type VoiceWakeConfig = {
triggers: string[];
@@ -22,37 +21,7 @@ function sanitizeTriggers(triggers: string[] | undefined | null): string[] {
return cleaned.length > 0 ? cleaned : DEFAULT_TRIGGERS;
}
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");
await fs.rename(tmp, filePath);
}
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();
export function defaultVoiceWakeTriggers() {
return [...DEFAULT_TRIGGERS];
@@ -60,7 +29,7 @@ export function defaultVoiceWakeTriggers() {
export async function loadVoiceWakeConfig(baseDir?: string): Promise<VoiceWakeConfig> {
const filePath = resolvePath(baseDir);
const existing = await readJSON<VoiceWakeConfig>(filePath);
const existing = await readJsonFile<VoiceWakeConfig>(filePath);
if (!existing) {
return { triggers: defaultVoiceWakeTriggers(), updatedAtMs: 0 };
}
@@ -84,7 +53,7 @@ export async function setVoiceWakeTriggers(
triggers: sanitized,
updatedAtMs: Date.now(),
};
await writeJSONAtomic(filePath, next);
await writeJsonAtomic(filePath, next);
return next;
});
}