mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-05-13 10:29:10 +00:00
build: compile dist/ [auto]
This commit is contained in:
142
dist/usage-api.js
vendored
142
dist/usage-api.js
vendored
@@ -13,14 +13,31 @@ const LEGACY_KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials';
|
||||
// File-based cache (HUD runs as new process each render, so in-memory cache won't persist)
|
||||
const CACHE_TTL_MS = 60_000; // 60 seconds
|
||||
const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests
|
||||
const CACHE_LOCK_STALE_MS = 30_000;
|
||||
const CACHE_LOCK_WAIT_MS = 2_000;
|
||||
const CACHE_LOCK_POLL_MS = 50;
|
||||
const KEYCHAIN_TIMEOUT_MS = 3000;
|
||||
const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting
|
||||
const USAGE_API_TIMEOUT_MS_DEFAULT = 15_000;
|
||||
export const USAGE_API_USER_AGENT = 'claude-code/2.1';
|
||||
export const USAGE_API_USER_AGENT = 'claude-hud';
|
||||
function getCachePath(homeDir) {
|
||||
return path.join(getHudPluginDir(homeDir), '.usage-cache.json');
|
||||
}
|
||||
function readCache(homeDir, now) {
|
||||
function getCacheLockPath(homeDir) {
|
||||
return path.join(getHudPluginDir(homeDir), '.usage-cache.lock');
|
||||
}
|
||||
function hydrateCacheData(data) {
|
||||
// JSON.stringify converts Date to ISO string, so we need to reconvert on read.
|
||||
// new Date() handles both Date objects and ISO strings safely.
|
||||
if (data.fiveHourResetAt) {
|
||||
data.fiveHourResetAt = new Date(data.fiveHourResetAt);
|
||||
}
|
||||
if (data.sevenDayResetAt) {
|
||||
data.sevenDayResetAt = new Date(data.sevenDayResetAt);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
function readCacheState(homeDir, now) {
|
||||
try {
|
||||
const cachePath = getCachePath(homeDir);
|
||||
if (!fs.existsSync(cachePath))
|
||||
@@ -29,23 +46,20 @@ function readCache(homeDir, now) {
|
||||
const cache = JSON.parse(content);
|
||||
// Check TTL - use shorter TTL for failure results
|
||||
const ttl = cache.data.apiUnavailable ? CACHE_FAILURE_TTL_MS : CACHE_TTL_MS;
|
||||
if (now - cache.timestamp >= ttl)
|
||||
return null;
|
||||
// JSON.stringify converts Date to ISO string, so we need to reconvert on read.
|
||||
// new Date() handles both Date objects and ISO strings safely.
|
||||
const data = cache.data;
|
||||
if (data.fiveHourResetAt) {
|
||||
data.fiveHourResetAt = new Date(data.fiveHourResetAt);
|
||||
}
|
||||
if (data.sevenDayResetAt) {
|
||||
data.sevenDayResetAt = new Date(data.sevenDayResetAt);
|
||||
}
|
||||
return data;
|
||||
return {
|
||||
data: hydrateCacheData(cache.data),
|
||||
timestamp: cache.timestamp,
|
||||
isFresh: now - cache.timestamp < ttl,
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function readCache(homeDir, now) {
|
||||
const cache = readCacheState(homeDir, now);
|
||||
return cache?.isFresh ? cache.data : null;
|
||||
}
|
||||
function writeCache(homeDir, data, timestamp) {
|
||||
try {
|
||||
const cachePath = getCachePath(homeDir);
|
||||
@@ -60,6 +74,78 @@ function writeCache(homeDir, data, timestamp) {
|
||||
// Ignore cache write failures
|
||||
}
|
||||
}
|
||||
function readLockTimestamp(lockPath) {
|
||||
try {
|
||||
if (!fs.existsSync(lockPath))
|
||||
return null;
|
||||
const raw = fs.readFileSync(lockPath, 'utf8').trim();
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function tryAcquireCacheLock(homeDir) {
|
||||
const lockPath = getCacheLockPath(homeDir);
|
||||
const cacheDir = path.dirname(lockPath);
|
||||
try {
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
const fd = fs.openSync(lockPath, 'wx');
|
||||
try {
|
||||
fs.writeFileSync(fd, String(Date.now()), 'utf8');
|
||||
}
|
||||
finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return 'acquired';
|
||||
}
|
||||
catch (error) {
|
||||
const maybeError = error;
|
||||
if (maybeError.code !== 'EEXIST') {
|
||||
debug('Usage cache lock unavailable, continuing without coordination:', maybeError.message);
|
||||
return 'unsupported';
|
||||
}
|
||||
}
|
||||
const lockTimestamp = readLockTimestamp(lockPath);
|
||||
if (lockTimestamp != null && Date.now() - lockTimestamp > CACHE_LOCK_STALE_MS) {
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
catch {
|
||||
return 'busy';
|
||||
}
|
||||
return tryAcquireCacheLock(homeDir);
|
||||
}
|
||||
return 'busy';
|
||||
}
|
||||
function releaseCacheLock(homeDir) {
|
||||
try {
|
||||
const lockPath = getCacheLockPath(homeDir);
|
||||
if (fs.existsSync(lockPath)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore lock cleanup failures
|
||||
}
|
||||
}
|
||||
async function waitForFreshCache(homeDir, now, timeoutMs = CACHE_LOCK_WAIT_MS) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS));
|
||||
const cached = readCache(homeDir, now());
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (!fs.existsSync(getCacheLockPath(homeDir))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return readCache(homeDir, now());
|
||||
}
|
||||
const defaultDeps = {
|
||||
homeDir: () => os.homedir(),
|
||||
fetchApi: fetchUsageApi,
|
||||
@@ -79,11 +165,24 @@ export async function getUsage(overrides = {}) {
|
||||
const now = deps.now();
|
||||
const homeDir = deps.homeDir();
|
||||
// Check file-based cache first
|
||||
const cached = readCache(homeDir, now);
|
||||
if (cached) {
|
||||
return cached;
|
||||
const cacheState = readCacheState(homeDir, now);
|
||||
if (cacheState?.isFresh) {
|
||||
return cacheState.data;
|
||||
}
|
||||
let holdsCacheLock = false;
|
||||
const lockStatus = tryAcquireCacheLock(homeDir);
|
||||
if (lockStatus === 'busy') {
|
||||
if (cacheState) {
|
||||
return cacheState.data;
|
||||
}
|
||||
return await waitForFreshCache(homeDir, deps.now);
|
||||
}
|
||||
holdsCacheLock = lockStatus === 'acquired';
|
||||
try {
|
||||
const refreshedCache = readCache(homeDir, deps.now());
|
||||
if (refreshedCache) {
|
||||
return refreshedCache;
|
||||
}
|
||||
const credentials = readCredentials(homeDir, now, deps.readKeychain);
|
||||
if (!credentials) {
|
||||
return null;
|
||||
@@ -132,6 +231,11 @@ export async function getUsage(overrides = {}) {
|
||||
debug('getUsage failed:', error);
|
||||
return null;
|
||||
}
|
||||
finally {
|
||||
if (holdsCacheLock) {
|
||||
releaseCacheLock(homeDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get path for keychain failure backoff cache.
|
||||
@@ -592,6 +696,10 @@ export function clearCache(homeDir) {
|
||||
if (fs.existsSync(cachePath)) {
|
||||
fs.unlinkSync(cachePath);
|
||||
}
|
||||
const lockPath = getCacheLockPath(homeDir);
|
||||
if (fs.existsSync(lockPath)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore
|
||||
|
||||
Reference in New Issue
Block a user