( f: (...args: Args) => Promise<Result>, cacheLifetimeMs: number = 5 * 60 * 1000, // Default 5 minutes )
| 118 | * @returns A memoized version of the async function |
| 119 | */ |
| 120 | export function memoizeWithTTLAsync<Args extends unknown[], Result>( |
| 121 | f: (...args: Args) => Promise<Result>, |
| 122 | cacheLifetimeMs: number = 5 * 60 * 1000, // Default 5 minutes |
| 123 | ): ((...args: Args) => Promise<Result>) & { cache: { clear: () => void } } { |
| 124 | const cache = new Map<string, CacheEntry<Result>>() |
| 125 | // In-flight cold-miss dedup. The old memoizeWithTTL (sync) accidentally |
| 126 | // provided this: it stored the Promise synchronously before the first |
| 127 | // await, so concurrent callers shared one f() invocation. This async |
| 128 | // variant awaits before cache.set, so concurrent cold-miss callers would |
| 129 | // each invoke f() independently without this map. For |
| 130 | // refreshAndGetAwsCredentials that means N concurrent `aws sso login` |
| 131 | // spawns. Same pattern as pending401Handlers in auth.ts:1171. |
| 132 | const inFlight = new Map<string, Promise<Result>>() |
| 133 | |
| 134 | const memoized = async (...args: Args): Promise<Result> => { |
| 135 | const key = jsonStringify(args) |
| 136 | const cached = cache.get(key) |
| 137 | const now = Date.now() |
| 138 | |
| 139 | // Populate cache - if this throws, nothing gets cached |
| 140 | if (!cached) { |
| 141 | const pending = inFlight.get(key) |
| 142 | if (pending) return pending |
| 143 | const promise = f(...args) |
| 144 | inFlight.set(key, promise) |
| 145 | try { |
| 146 | const result = await promise |
| 147 | // Identity-guard: cache.clear() during the await should discard this |
| 148 | // result (clear intent is to invalidate). If we're still in-flight, |
| 149 | // store it. clear() wipes inFlight too, so this check catches that. |
| 150 | if (inFlight.get(key) === promise) { |
| 151 | cache.set(key, { |
| 152 | value: result, |
| 153 | timestamp: now, |
| 154 | refreshing: false, |
| 155 | }) |
| 156 | } |
| 157 | return result |
| 158 | } finally { |
| 159 | if (inFlight.get(key) === promise) { |
| 160 | inFlight.delete(key) |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // If we have a stale cache entry and it's not already refreshing |
| 166 | if ( |
| 167 | cached && |
| 168 | now - cached.timestamp > cacheLifetimeMs && |
| 169 | !cached.refreshing |
| 170 | ) { |
| 171 | // Mark as refreshing to prevent multiple parallel refreshes |
| 172 | cached.refreshing = true |
| 173 | |
| 174 | // Schedule async refresh (non-blocking). Both .then and .catch are |
| 175 | // identity-guarded against a concurrent cache.clear() + cold-miss |
| 176 | // storing a newer entry while this refresh is in flight. .then |
| 177 | // overwriting with the stale refresh's result is worse than .catch |
no test coverage detected