JSON files
@openclaw/fs-safe/json is the standalone JSON surface — strict and lenient read variants, atomic writes, and a small async lock for serializing in-process writers.
import {
tryReadJson,
readJson,
readJsonIfExists,
readJsonSync,
tryReadJsonSync,
writeJson,
writeText,
writeJsonSync,
createAsyncLock,
JsonFileReadError,
} from "@openclaw/fs-safe/json";
#Three reads, three failure shapes
Same input, three distinct contracts — pick the one whose error story matches your call site:
await readJson<T>("./manifest.json"); // throws JsonFileReadError on missing or invalid
await readJsonIfExists<T>("./cache.json"); // returns null on missing; throws on invalid
await tryReadJson<T>("./optional.json"); // returns null on missing or invalid
| Helper | Missing file | Invalid JSON |
|---|---|---|
readJson | throws | throws |
readJsonIfExists | null | throws |
tryReadJson | null | null |
Use readJson when missing-or-malformed is a programmer error you want to surface immediately. Use readJsonIfExists when "file not there" is normal but malformed bytes should still page someone. Use tryReadJson when neither outcome should crash the caller.
JsonFileReadError carries cause so you can inspect whether the underlying failure was an ENOENT, a SyntaxError, or something else.
#Reading
#readJson<T>(filePath)
Async strict reader. Throws JsonFileReadError on missing or invalid input. The cast is unchecked — validate the shape with your own schema (zod, valibot, …) if it came from an untrusted source.
const manifest = await readJson<Manifest>("./manifest.json");
#readJsonIfExists<T>(filePath)
Async semi-lenient reader. Returns null if the file is missing; throws JsonFileReadError if the file exists but cannot be parsed.
const cache = (await readJsonIfExists<Cache>("./cache.json")) ?? freshCache();
#tryReadJson<T>(filePath)
Async lenient reader. Returns null for any failure (missing, unreadable, invalid). The "no fuss" sibling.
const optional = (await tryReadJson<Settings>("./settings.json")) ?? defaults;
#readJsonSync<T>(filePath)
Synchronous strict reader. Throws JsonFileReadError on missing or invalid input, matching the async readJson contract.
#tryReadJsonSync<T>(pathname)
Synchronous, generic, lenient. Returns T | null. Useful in boot paths where you want a typed result without async.
#Writing
#writeJson(filePath, value, options?)
Async atomic JSON write. JSON.stringify(value, null, 2) + sibling-temp + rename. Defaults to file mode 0o600.
await writeJson("./state.json", state, { trailingNewline: true });
Options:
type WriteJsonOptions = {
mode?: number; // file mode (default 0o600)
dirMode?: number; // mode for parent dirs created on demand
trailingNewline?: boolean; // append "\n" if missing (default false)
};
#writeText(filePath, content, options?)
Async atomic text write. Same options as writeJson (minus the JSON-specific behavior).
await writeText("./README.md", rendered, { trailingNewline: true });
#writeJsonSync(pathname, data)
Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults.
writeJsonSync("./prefs.json", { theme: "dark" });
#Concurrency: createAsyncLock()
For in-process serialization of writers to the same file. Returns a function that runs an async task under the lock:
import { createAsyncLock } from "@openclaw/fs-safe/json";
const lock = createAsyncLock();
async function bumpCounter() {
return lock(async () => {
const state = (await readJsonIfExists<{ count: number }>("./counter.json")) ?? { count: 0 };
state.count += 1;
await writeJson("./counter.json", state);
return state.count;
});
}
The lock is in-process only — it does nothing for cross-process coordination. For multi-process locking, see createSidecarLockManager or jsonStore.
#Common patterns
#Read-modify-write
const state = (await readJsonIfExists<State>("./state.json")) ?? initialState();
state.lastRun = Date.now();
await writeJson("./state.json", state, { mode: 0o600, dirMode: 0o700 });
#Atomic with secure mode
For credentials or other sensitive JSON, write at mode 0o600:
await writeJson("./auth.json", token, { mode: 0o600, dirMode: 0o700 });
For higher-assurance secrets, prefer the dedicated secret-file helpers — they create the parent directory at 0o700 if missing.
#Strict load on boot
let manifest: Manifest;
try {
manifest = await readJson<Manifest>("./manifest.json");
} catch (err) {
if (err instanceof JsonFileReadError) {
console.error("manifest unreadable:", err.cause);
process.exit(1);
}
throw err;
}
#Concurrent readers, single writer
const state = await readJsonIfExists<State>("./state.json");
// missing returns null; malformed JSON still throws
#Error reference
| Throw / return | When |
|---|---|
null (lenient reads) | File missing or contents are not valid JSON. |
JsonFileReadError | readJson or readJsonIfExists saw unreadable or invalid input. Inspect cause. |
Native NodeJS.ErrnoException | Lower-level fs errors not wrapped. |
#See also
- JSON store — a single-file state wrapper with fallback and optional sidecar locking.
- Atomic writes — lower-level sibling-temp replacement helpers.
- Secret files — JSON-or-text writes with mode 0600 in mode 0700 dirs.
- Private state store — root-bounded JSON+text helpers.
- Sidecar lock — cross-process coordination.