codev / src /server /services /recoverableJsonFile.ts
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame Contribute Delete
2.37 kB
import * as fs from 'fs/promises'
import { randomBytes } from 'node:crypto'
import { ApiError } from '../middleware/errorHandler.js'
type RecoverableJsonFileOptions<T> = {
filePath: string
label: string
defaultValue: T
normalize: (value: unknown) => T | null
}
function cloneDefault<T>(value: T): T {
if (value && typeof value === 'object') {
return JSON.parse(JSON.stringify(value)) as T
}
return value
}
function errnoCode(error: unknown): string | undefined {
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: undefined
}
async function quarantineInvalidJsonFile(
filePath: string,
label: string,
reason: string,
): Promise<void> {
const backupPath = `${filePath}.invalid-${Date.now()}-${randomBytes(3).toString('hex')}`
try {
await fs.rename(filePath, backupPath)
console.warn(`[desktop] Recovered invalid ${label}; moved ${filePath} to ${backupPath}: ${reason}`)
} catch (error) {
console.warn(
`[desktop] Recovered invalid ${label} from ${filePath}, but failed to quarantine it: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
export function normalizeJsonObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
export async function readRecoverableJsonFile<T>({
filePath,
label,
defaultValue,
normalize,
}: RecoverableJsonFileOptions<T>): Promise<T> {
let raw: string
try {
raw = await fs.readFile(filePath, 'utf-8')
} catch (error) {
if (errnoCode(error) === 'ENOENT') {
return cloneDefault(defaultValue)
}
throw ApiError.internal(`Failed to read ${label} from ${filePath}: ${error}`)
}
if (raw.trim() === '') {
return cloneDefault(defaultValue)
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error) {
await quarantineInvalidJsonFile(
filePath,
label,
error instanceof Error ? error.message : String(error),
)
return cloneDefault(defaultValue)
}
const normalized = normalize(parsed)
if (normalized === null) {
await quarantineInvalidJsonFile(filePath, label, 'unexpected JSON shape')
return cloneDefault(defaultValue)
}
return normalized
}