-
Notifications
You must be signed in to change notification settings - Fork 66
Expand file tree
/
Copy pathgithub-cache.mts
More file actions
129 lines (113 loc) · 4.43 KB
/
Copy pathgithub-cache.mts
File metadata and controls
129 lines (113 loc) · 4.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
/**
* Persistent + in-memory response cache for GitHub API calls used by Socket
* CLI. 5-minute TTL, automatic invalidation, and a persistent cache in
* node_modules/.cache.
*/
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { LRUCache } from 'lru-cache'
import { readJson } from '@socketsecurity/lib-stable/fs/read-json'
import { safeMkdir } from '@socketsecurity/lib-stable/fs/safe'
import { writeJson } from '@socketsecurity/lib-stable/fs/write-json'
import { DISABLE_GITHUB_CACHE } from '../../env/disable-github-cache.mts'
import { getGithubCachePath } from '../../constants/paths.mts'
import type { JsonContent } from '@socketsecurity/lib-stable/fs/types'
export interface CacheEntry {
timestamp: number
data: JsonContent
}
// In-memory promise cache to prevent concurrent fetches for the same key.
// LRU cache with max size to prevent unbounded memory growth.
const inflightRequests = new LRUCache<string, Promise<unknown>>({ max: 100 })
export async function cacheFetch<T>(
key: string,
fetcher: () => Promise<T>,
ttlMs?: number | undefined,
): Promise<T> {
/* c8 ignore start - DISABLE_GITHUB_CACHE not set in tests */
if (DISABLE_GITHUB_CACHE) {
return await fetcher()
}
/* c8 ignore stop */
// Check if already fetching this key to prevent TOCTOU race.
const inflight = inflightRequests.get(key)
/* c8 ignore start - inflight cache hit requires concurrent calls; tests run serially */
if (inflight) {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the LRU stores every key's inflight promise as Promise<unknown>; a same-key hit was produced by the same fetcher, so its resolved type is this call's T.
return inflight as Promise<T>
}
/* c8 ignore stop */
try {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- JSON-file cache boundary: the persisted value was serialized from a T by writeCache, but a disk round-trip can't carry the compile-time type.
let data = (await readCache(key, ttlMs)) as T
if (!data) {
// Re-check inflight after async readCache to prevent race.
const inflightAfterRead = inflightRequests.get(key)
if (inflightAfterRead) {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- same-key inflight promise, see above: produced by the same fetcher, resolves to this call's T.
return inflightAfterRead as Promise<T>
}
const fetchPromise = (async () => {
try {
const result = await fetcher()
await writeCache(key, result)
return result
} finally {
inflightRequests.delete(key)
}
})()
inflightRequests.set(key, fetchPromise)
data = await fetchPromise
}
return data
} catch (e) {
// Fetch promise's finally block handles cleanup - no action needed here.
throw e
}
}
export async function readCache(
key: string,
// 5 minute in milliseconds time to live (TTL).
ttlMs = 5 * 60 * 1000,
): Promise<JsonContent | undefined> {
const githubCachePath = getGithubCachePath()
const cacheJsonPath = path.join(githubCachePath, `${key}.json`)
try {
const entry = await readJson(cacheJsonPath)
// Handle both new format, with timestamp, and legacy format (without).
if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) {
const { data, timestamp } = entry
/* c8 ignore start - cache fresh-hit + legacy-format branches; tests pre-populate cache files in only one format */
if (typeof timestamp === 'number' && data !== undefined) {
const isExpired = Date.now() - timestamp > ttlMs
if (!isExpired) {
return data
}
} else {
// Legacy format without timestamp - treat as expired.
return undefined
}
/* c8 ignore stop */
}
} catch {
return undefined
}
return undefined
}
export async function writeCache(
key: string,
data: JsonContent,
): Promise<void> {
const githubCachePath = getGithubCachePath()
const cacheJsonPath = path.join(githubCachePath, `${key}.json`)
// Create directory with recursive flag that doesn't fail if exists.
await safeMkdir(githubCachePath, { recursive: true })
const entry: CacheEntry = {
timestamp: Date.now(),
data,
}
// Use atomic write pattern to prevent multi-process race conditions.
const tmpPath = `${cacheJsonPath}.tmp.${process.pid}`
await writeJson(tmpPath, entry)
await fs.rename(tmpPath, cacheJsonPath)
}