From c9bb6bd0d8689db5669a5e64fef4725211643254 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:45:38 +0000 Subject: [PATCH] refactor(infra): extract json file + async lock helpers --- src/infra/json-files.ts | 52 ++++++++++++++++++++++++++++++++++++++ src/infra/pairing-files.ts | 48 ++--------------------------------- src/infra/voicewake.ts | 39 +++------------------------- 3 files changed, 58 insertions(+), 81 deletions(-) create mode 100644 src/infra/json-files.ts diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts new file mode 100644 index 000000000..d71cbf763 --- /dev/null +++ b/src/infra/json-files.ts @@ -0,0 +1,52 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function readJsonFile(filePath: string): Promise { + 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 = Promise.resolve(); + return async function withLock(fn: () => Promise): Promise { + const prev = lock; + let release: (() => void) | undefined; + lock = new Promise((resolve) => { + release = resolve; + }); + await prev; + try { + return await fn(); + } finally { + release?.(); + } + }; +} diff --git a/src/infra/pairing-files.ts b/src/infra/pairing-files.ts index fb6c3ee5e..f2578facd 100644 --- a/src/infra/pairing-files.ts +++ b/src/infra/pairing-files.ts @@ -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(filePath: string): Promise { - 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( pendingById: Record, nowMs: number, @@ -51,20 +24,3 @@ export function pruneExpiredPending( } } } - -export function createAsyncLock() { - let lock: Promise = Promise.resolve(); - return async function withLock(fn: () => Promise): Promise { - const prev = lock; - let release: (() => void) | undefined; - lock = new Promise((resolve) => { - release = resolve; - }); - await prev; - try { - return await fn(); - } finally { - release?.(); - } - }; -} diff --git a/src/infra/voicewake.ts b/src/infra/voicewake.ts index 9d0867a0a..ee73c8e40 100644 --- a/src/infra/voicewake.ts +++ b/src/infra/voicewake.ts @@ -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(filePath: string): Promise { - 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 = Promise.resolve(); -async function withLock(fn: () => Promise): Promise { - const prev = lock; - let release: (() => void) | undefined; - lock = new Promise((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 { const filePath = resolvePath(baseDir); - const existing = await readJSON(filePath); + const existing = await readJsonFile(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; }); }