splitwise / src /lib /server /app.ts
assafvayner's picture
assafvayner HF Staff
fix(deploy): trust x-forwarded-for for per-IP rate limiting
d0c44b2
Raw
History Blame Contribute Delete
5.71 kB
import { randomUUID } from 'node:crypto';
import { RateLimiter } from './auth/rate-limit';
import { loadConfig, type Config } from './config';
import { Ledger, type CompactionResult } from './ledger/ledger';
import { LOCK_PATH, WriterGuard } from './ledger/writer-guard';
import { HfBucketStore } from './storage/hf-bucket-store';
import { LocalFsStore } from './storage/local-fs-store';
import { PrefixedStore } from './storage/prefixed-store';
import type { BucketStore } from './storage/store';
import { UsersRepo } from './users/repository';
export interface CompactionStatus {
lastRunAt: string | null;
lastResult: CompactionResult | null;
lastError: string | null;
running: boolean;
}
export interface App {
config: Config;
store: BucketStore;
users: UsersRepo;
ledger: Ledger;
guard: WriterGuard;
loginLimiter: RateLimiter;
/** Second login gate, keyed by client IP, so one host cannot spray many usernames. */
ipLimiter: RateLimiter;
compaction: CompactionStatus;
/** Aligns the ledger's read-only flag with the writer lock, logging any transition. */
syncReadOnly(): void;
runCompaction(): Promise<CompactionResult>;
}
const BUILD_RETRY_AFTER_MS = 10_000;
let instance: Promise<App> | null = null;
let failedAt = 0;
/** Builds the singleton on first call; subsequent calls return the same promise. */
export function getApp(): Promise<App> {
if (instance && (failedAt === 0 || Date.now() - failedAt < BUILD_RETRY_AFTER_MS)) return instance;
failedAt = 0;
instance = build().catch((e) => {
failedAt = Date.now();
throw e;
});
return instance;
}
async function build(): Promise<App> {
const config = loadConfig();
const base: BucketStore =
config.storage === 'hf' ? new HfBucketStore(config.hfBucket, config.hfToken) : new LocalFsStore(config.localDataDir);
const store: BucketStore = config.hfPrefix ? new PrefixedStore(base, config.hfPrefix) : base;
console.log(
`[app] storage=${config.storage}${config.storage === 'hf' ? ` bucket=${config.hfBucket}` : ` dir=${config.localDataDir}`}${config.hfPrefix ? ` prefix=${config.hfPrefix}/` : ''}`
);
const users = await UsersRepo.load(store);
const guard = new WriterGuard(store, randomUUID());
const acquired = await guard.acquire();
if (!acquired) console.error('[app] another instance holds the writer lock; starting READ-ONLY until it goes stale');
try {
const ledger = await Ledger.load(store);
ledger.readOnly = !guard.canWrite;
guard.start();
const compaction: CompactionStatus = { lastRunAt: null, lastResult: null, lastError: null, running: false };
let inFlight: Promise<CompactionResult> | null = null;
const compactOnce = async (): Promise<CompactionResult> => {
compaction.running = true;
try {
const result = await ledger.compact();
compaction.lastResult = result;
compaction.lastError = null;
return result;
} catch (e) {
compaction.lastError = e instanceof Error ? e.message : String(e);
throw e;
} finally {
compaction.running = false;
compaction.lastRunAt = new Date().toISOString();
}
};
const app: App = {
config,
store,
users,
ledger,
guard,
loginLimiter: new RateLimiter(5, 60_000),
ipLimiter: new RateLimiter(30, 60_000),
compaction,
syncReadOnly() {
const readOnly = !guard.canWrite;
if (readOnly === ledger.readOnly) return;
ledger.readOnly = readOnly;
console.log(readOnly ? '[app] entering READ-ONLY mode' : '[app] writer lock acquired, leaving read-only mode');
},
runCompaction() {
inFlight ??= compactOnce().finally(() => {
inFlight = null;
});
return inFlight;
}
};
const timer = setInterval(() => {
app.syncReadOnly();
if (ledger.readOnly) return;
app.runCompaction().catch((e) => console.error('[compaction] failed', e));
}, config.compactionIntervalMs);
timer.unref?.();
registerShutdown(app, timer);
if (guard.canWrite && ledger.stats().pendingEvents > 0) {
app.runCompaction().catch((e) => console.error('[compaction] boot compaction failed', e));
}
console.log(
`[app] ready: ${users.all().length} users, ${ledger.listExpenses().length} expenses, ${ledger.stats().pendingEvents} pending events`
);
return app;
} catch (e) {
guard.stop();
throw e;
}
}
let shutdownRegistered = false;
/**
* Hands the writer lock back on the way out so a redeploy does not have to wait for the
* lock to go stale. adapter-node emits `sveltekit:shutdown` once the HTTP server is closed;
* the signal handlers are a fallback for hosts that do not (e.g. `vite dev`).
*/
function registerShutdown(app: App, timer: ReturnType<typeof setInterval>): void {
if (shutdownRegistered) return;
shutdownRegistered = true;
let released: Promise<void> | null = null;
const release = (): Promise<void> => {
released ??= (async () => {
clearInterval(timer);
app.guard.stop();
if (!app.guard.canWrite) return;
try {
const bail = new Promise<never>((_, reject) => {
const t = setTimeout(() => reject(new Error('timed out')), 5_000);
t.unref?.();
});
await Promise.race([app.store.delete([LOCK_PATH]), bail]);
console.log('[app] released writer lock');
} catch (e) {
console.warn('[app] could not release writer lock', e);
}
})();
return released;
};
const emitter = process as NodeJS.EventEmitter;
emitter.on('sveltekit:shutdown', () => void release());
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
// If nothing else listens for this signal our handler would otherwise swallow it.
const sole = process.listenerCount(signal) === 0;
release().finally(() => {
if (sole) process.exit(0);
});
});
}
}