aeStatusPage/src/atomicJson.js
jmcqueen 007086caf6 Initial commit: status-page bridge with Webex bot management
Bridges third-party status pages into Webex spaces via RSS polling and
inbound webhooks (Statuspage / Status.io / Uptime Kuma / generic).

Includes an interactive Webex bot (websocket transport) that lets space
members register sources with an Adaptive Card instead of hand-editing
config/feeds.json: help, add, list, webhook <key>, remove <key>.

Ships with an atomic JSON store (per-file mutex, tmp+rename), parallel
RSS polling, and unit tests via node:test. All secrets are sourced from
environment variables (see .env.example); no credentials in the repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:08:34 -04:00

53 lines
1.8 KiB
JavaScript

import fs from 'fs';
import path from 'path';
// Per-file promise chain. Any writer for a given path queues behind the previous
// one so we never have two writers renaming into the same target concurrently.
const locks = new Map();
function withLock(key, fn) {
const prev = locks.get(key) || Promise.resolve();
// Ignore the previous task's result/errors — every waiter still gets a chance to run.
const next = prev.then(fn, fn);
const tail = next.catch(() => {});
locks.set(key, tail);
tail.then(() => {
if (locks.get(key) === tail) locks.delete(key);
});
return next;
}
export function readJSON(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
// Write JSON atomically: serialize -> write to tmp in the same directory ->
// fsync -> rename over the target. Rename within a single filesystem is atomic
// on POSIX, so readers either see the old or new file, never a torn write.
export function writeJSON(filePath, data) {
return withLock(path.resolve(filePath), async () => {
const dir = path.dirname(filePath);
const base = path.basename(filePath);
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
const payload = JSON.stringify(data, null, 4);
const fh = await fs.promises.open(tmp, 'w');
try {
await fh.writeFile(payload);
await fh.sync();
} finally {
await fh.close();
}
try {
await fs.promises.rename(tmp, filePath);
} catch (error) {
// If rename fails, don't leave the tmp file behind.
fs.promises.unlink(tmp).catch(() => {});
throw error;
}
});
}
// Exported for tests only.
export const __test__ = { withLock, locks };