55 * Supports daily, monthly, and session-based usage data aggregation.
66 *
77 * Performance optimizations:
8- * - TTL-based caching to reduce better-ccusage library calls
8+ * - Persistent disk cache to avoid re-parsing JSONL files on startup
9+ * - TTL-based in-memory caching for fast repeated requests
910 * - Request coalescing to prevent duplicate concurrent requests
11+ * - Non-blocking prewarm with instant stale data serving
1012 */
1113
1214import { Router , Request , Response } from 'express' ;
@@ -18,6 +20,14 @@ import {
1820 type MonthlyUsage ,
1921 type SessionUsage ,
2022} from 'better-ccusage/data-loader' ;
23+ import {
24+ readDiskCache ,
25+ writeDiskCache ,
26+ isDiskCacheFresh ,
27+ isDiskCacheStale ,
28+ clearDiskCache ,
29+ getCacheAge ,
30+ } from './usage-disk-cache' ;
2131
2232export const usageRoutes = Router ( ) ;
2333
@@ -50,8 +60,9 @@ const CACHE_TTL = {
5060 session : 60 * 1000 , // 1 minute - user may refresh
5161} ;
5262
53- // Stale-while-revalidate: max age for stale data (1 hour)
54- const STALE_TTL = 60 * 60 * 1000 ;
63+ /// Stale-while-revalidate: max age for stale data (7 days)
64+ // We always show cached data to avoid blocking UI, refresh happens in background
65+ const STALE_TTL = 7 * 24 * 60 * 60 * 1000 ;
5566
5667// Track when data was last fetched (for UI indicator)
5768let lastFetchTimestamp : number | null = null ;
@@ -67,12 +78,34 @@ const cache = new Map<string, CacheEntry<unknown>>();
6778// Pending requests for coalescing (prevents duplicate concurrent calls)
6879const pendingRequests = new Map < string , Promise < unknown > > ( ) ;
6980
81+ // Track if disk cache has been loaded into memory
82+ let diskCacheInitialized = false ;
83+
84+ /**
85+ * Persist cache to disk when we have enough data to be useful.
86+ * Writes immediately with whatever data is available (empty arrays for missing).
87+ * This ensures disk cache is created after first Analytics page visit.
88+ */
89+ function persistCacheIfComplete ( ) : void {
90+ const daily = cache . get ( 'daily' ) as CacheEntry < DailyUsage [ ] > | undefined ;
91+ const monthly = cache . get ( 'monthly' ) as CacheEntry < MonthlyUsage [ ] > | undefined ;
92+ const session = cache . get ( 'session' ) as CacheEntry < SessionUsage [ ] > | undefined ;
93+
94+ // Write if we have at least daily data (the most essential)
95+ if ( daily ) {
96+ writeDiskCache ( daily . data , monthly ?. data ?? [ ] , session ?. data ?? [ ] ) ;
97+ }
98+ }
99+
70100/**
71101 * Get cached data or fetch from loader with TTL
72102 * Also coalesces concurrent requests to prevent duplicate library calls
73103 * Implements stale-while-revalidate pattern for instant responses
74104 */
75105async function getCachedData < T > ( key : string , ttl : number , loader : ( ) => Promise < T > ) : Promise < T > {
106+ // Ensure disk cache is loaded on first request
107+ ensureDiskCacheLoaded ( ) ;
108+
76109 const cached = cache . get ( key ) as CacheEntry < T > | undefined ;
77110 const now = Date . now ( ) ;
78111
@@ -89,6 +122,8 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
89122 . then ( ( data ) => {
90123 cache . set ( key , { data, timestamp : Date . now ( ) } ) ;
91124 lastFetchTimestamp = Date . now ( ) ;
125+ // Persist to disk if all data types are cached
126+ persistCacheIfComplete ( ) ;
92127 } )
93128 . catch ( ( err ) => {
94129 console . error ( `[!] Background refresh failed for ${ key } :` , err ) ;
@@ -112,6 +147,8 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
112147 . then ( ( data ) => {
113148 cache . set ( key , { data, timestamp : Date . now ( ) } ) ;
114149 lastFetchTimestamp = Date . now ( ) ;
150+ // Persist to disk if all data types are cached
151+ persistCacheIfComplete ( ) ;
115152 return data ;
116153 } )
117154 . finally ( ( ) => {
@@ -148,24 +185,135 @@ async function getCachedSessionData(): Promise<SessionUsage[]> {
148185 */
149186export function clearUsageCache ( ) : void {
150187 cache . clear ( ) ;
188+ clearDiskCache ( ) ;
189+ // Reset so next API call will try to reload from disk/source
190+ diskCacheInitialized = false ;
191+ }
192+
193+ // Track if background refresh is in progress
194+ let isRefreshing = false ;
195+
196+ /**
197+ * Load fresh data from better-ccusage and update both memory and disk caches
198+ */
199+ async function refreshFromSource ( ) : Promise < {
200+ daily : DailyUsage [ ] ;
201+ monthly : MonthlyUsage [ ] ;
202+ session : SessionUsage [ ] ;
203+ } > {
204+ const [ daily , monthly , session ] = await Promise . all ( [
205+ loadDailyUsageData ( ) as Promise < DailyUsage [ ] > ,
206+ loadMonthlyUsageData ( ) as Promise < MonthlyUsage [ ] > ,
207+ loadSessionData ( ) as Promise < SessionUsage [ ] > ,
208+ ] ) ;
209+
210+ // Update in-memory cache
211+ const now = Date . now ( ) ;
212+ cache . set ( 'daily' , { data : daily , timestamp : now } ) ;
213+ cache . set ( 'monthly' , { data : monthly , timestamp : now } ) ;
214+ cache . set ( 'session' , { data : session , timestamp : now } ) ;
215+ lastFetchTimestamp = now ;
216+
217+ // Persist to disk
218+ writeDiskCache ( daily , monthly , session ) ;
219+
220+ return { daily, monthly, session } ;
221+ }
222+
223+ // ============================================================================
224+ // Module Initialization - Load disk cache immediately for instant API responses
225+ // ============================================================================
226+
227+ /**
228+ * Initialize in-memory cache from disk cache (lazy - called on first API request).
229+ * This ensures first API request gets instant data without calling better-ccusage.
230+ * Background refresh is NOT triggered here - it happens via SWR pattern in getCachedData().
231+ */
232+ function ensureDiskCacheLoaded ( ) : void {
233+ if ( diskCacheInitialized ) return ;
234+ diskCacheInitialized = true ;
235+
236+ const diskCache = readDiskCache ( ) ;
237+ if ( ! diskCache ) return ;
238+
239+ // Load disk cache into memory (regardless of freshness)
240+ // SWR pattern in getCachedData() will handle background refresh
241+ cache . set ( 'daily' , { data : diskCache . daily , timestamp : diskCache . timestamp } ) ;
242+ cache . set ( 'monthly' , { data : diskCache . monthly , timestamp : diskCache . timestamp } ) ;
243+ cache . set ( 'session' , { data : diskCache . session , timestamp : diskCache . timestamp } ) ;
244+ lastFetchTimestamp = diskCache . timestamp ;
151245}
152246
153247/**
154248 * Pre-warm usage caches on server startup
155- * Loads all usage data into cache so first user request is instant
156- * Returns timestamp when cache was populated
249+ *
250+ * Strategy:
251+ * 1. Check disk cache - if fresh, use it (instant startup)
252+ * 2. If stale, use it immediately but trigger background refresh
253+ * 3. If no cache, return immediately and let first request trigger load
254+ *
255+ * This ensures dashboard opens in <1s regardless of cache state
157256 */
158- export async function prewarmUsageCache ( ) : Promise < { timestamp : number ; elapsed : number } > {
257+ export async function prewarmUsageCache ( ) : Promise < {
258+ timestamp : number ;
259+ elapsed : number ;
260+ source : string ;
261+ } > {
159262 const start = Date . now ( ) ;
160263 console . log ( '[i] Pre-warming usage cache...' ) ;
161264
162265 try {
163- await Promise . all ( [ getCachedDailyData ( ) , getCachedMonthlyData ( ) , getCachedSessionData ( ) ] ) ;
266+ const diskCache = readDiskCache ( ) ;
267+
268+ // Fresh disk cache - use it directly
269+ if ( diskCache && isDiskCacheFresh ( diskCache ) ) {
270+ const now = Date . now ( ) ;
271+ cache . set ( 'daily' , { data : diskCache . daily , timestamp : diskCache . timestamp } ) ;
272+ cache . set ( 'monthly' , { data : diskCache . monthly , timestamp : diskCache . timestamp } ) ;
273+ cache . set ( 'session' , { data : diskCache . session , timestamp : diskCache . timestamp } ) ;
274+ lastFetchTimestamp = diskCache . timestamp ;
275+
276+ const elapsed = Date . now ( ) - start ;
277+ console . log (
278+ `[OK] Usage cache ready from disk (${ elapsed } ms, cached ${ getCacheAge ( diskCache ) } )`
279+ ) ;
280+ return { timestamp : now , elapsed, source : 'disk-fresh' } ;
281+ }
282+
283+ // Stale disk cache - use it immediately, refresh in background
284+ if ( diskCache && isDiskCacheStale ( diskCache ) ) {
285+ const now = Date . now ( ) ;
286+ cache . set ( 'daily' , { data : diskCache . daily , timestamp : diskCache . timestamp } ) ;
287+ cache . set ( 'monthly' , { data : diskCache . monthly , timestamp : diskCache . timestamp } ) ;
288+ cache . set ( 'session' , { data : diskCache . session , timestamp : diskCache . timestamp } ) ;
289+ lastFetchTimestamp = diskCache . timestamp ;
290+
291+ const elapsed = Date . now ( ) - start ;
292+ console . log (
293+ `[OK] Usage cache ready from disk (${ elapsed } ms, stale ${ getCacheAge ( diskCache ) } , refreshing...)`
294+ ) ;
295+
296+ // Background refresh
297+ if ( ! isRefreshing ) {
298+ isRefreshing = true ;
299+ refreshFromSource ( )
300+ . then ( ( ) => console . log ( '[OK] Background refresh complete' ) )
301+ . catch ( ( err ) => console . error ( '[!] Background refresh failed:' , err ) )
302+ . finally ( ( ) => {
303+ isRefreshing = false ;
304+ } ) ;
305+ }
306+
307+ return { timestamp : now , elapsed, source : 'disk-stale' } ;
308+ }
309+
310+ // No usable disk cache - refresh from source (blocking for first startup only)
311+ console . log ( '[i] No disk cache, loading from source...' ) ;
312+ await refreshFromSource ( ) ;
164313
165314 const elapsed = Date . now ( ) - start ;
166- lastFetchTimestamp = Date . now ( ) ;
167315 console . log ( `[OK] Usage cache ready (${ elapsed } ms)` ) ;
168- return { timestamp : lastFetchTimestamp , elapsed } ;
316+ return { timestamp : Date . now ( ) , elapsed, source : 'fresh' } ;
169317 } catch ( err ) {
170318 console . error ( '[!] Failed to prewarm usage cache:' , err ) ;
171319 throw err ;
0 commit comments