Skip to content

Commit 69cf036

Browse files
committed
feat: add caching layer
Signed-off-by: Liran Tal <liran.tal@gmail.com>
1 parent c7abe2c commit 69cf036

File tree

3 files changed

+144
-27
lines changed

3 files changed

+144
-27
lines changed

src/resources/index.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,26 @@ import {
44
ReadResourceRequestSchema,
55
} from '@modelcontextprotocol/sdk/types.js'
66
import { initLogger, type Logger } from '../utils/logger.ts'
7+
import { CacheService } from '../services/cache-service.ts'
78
import type { ReadResourceRequest } from '@modelcontextprotocol/sdk/types.js'
89

910
const logger: Logger = initLogger()
11+
const cacheService = new CacheService()
1012

1113
export async function initializeResources (server: Server): Promise<void> {
1214
logger.info({ msg: 'Initializing resources...' })
1315

1416
const resourceNodejsReleasesChartURL =
1517
'https://raw.githubusercontent.com/nodejs/Release/main/schedule.svg?sanitize=true'
16-
const resourceNodejsReleasesChart = await fetch(
17-
resourceNodejsReleasesChartURL
18-
)
19-
20-
if (!resourceNodejsReleasesChart.ok) {
21-
logger.error({
22-
msg: `Failed to fetch Node.js releases chart: ${resourceNodejsReleasesChart.status} ${resourceNodejsReleasesChart.statusText}`,
23-
})
24-
throw new Error(
25-
`Failed to fetch Node.js releases chart: ${resourceNodejsReleasesChart.status} ${resourceNodejsReleasesChart.statusText}`
26-
)
27-
}
2818

29-
const resourceNodejsReleasesChartSVGText =
30-
await resourceNodejsReleasesChart.text()
19+
// Use cache service to fetch the SVG with 7-day expiration
20+
const resourceNodejsReleasesChartSVGText = await cacheService.fetchHttpWithCache(
21+
resourceNodejsReleasesChartURL,
22+
{
23+
responseType: 'text',
24+
ttlDays: 7
25+
}
26+
)
3127

3228
const resources = [
3329
{

src/services/api-docs-service.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { initLogger, type Logger } from '../utils/logger.ts'
22
import { DocsFormatter } from './docs-formatter-service.ts'
3+
import { CacheService } from './cache-service.ts'
34

45
// Type definitions for Node.js API documentation structure
56
interface ApiMethod {
@@ -43,30 +44,23 @@ interface FormattingOptions {
4344
export class ApiDocsService {
4445
private logger: Logger
4546
private docsFormatter: DocsFormatter
47+
private cacheService: CacheService
4648
private url: string
4749
private modulesData: ModulesData | null
4850

4951
constructor () {
5052
this.logger = initLogger()
5153
this.docsFormatter = new DocsFormatter()
54+
this.cacheService = new CacheService()
5255
this.url = 'https://nodejs.org/docs/latest/api/all.json'
5356
this.modulesData = null
5457
}
5558

5659
async fetchNodeApiDocs (): Promise<ApiDocsData> {
57-
this.logger.info({ msg: 'Fetching Node.js API documentation...' })
58-
try {
59-
const response = await fetch(this.url)
60-
if (!response.ok) {
61-
throw new Error(`HTTP error! status: ${response.status} ${response.statusText}`)
62-
}
63-
const data = await response.json() as ApiDocsData
64-
this.logger.info({ msg: 'Successfully fetched Node.js API documentation', url: this.url })
65-
return data
66-
} catch (error) {
67-
this.logger.error({ err: error, msg: `Failed to fetch Node.js API documentation: ${this.url}` })
68-
throw error
69-
}
60+
return this.cacheService.fetchHttpWithCache(this.url, {
61+
responseType: 'json',
62+
ttlDays: 7 // Cache for 7 days
63+
}) as Promise<ApiDocsData>
7064
}
7165

7266
normalizeModuleName (name: string): string {

src/services/cache-service.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { initLogger, type Logger } from '../utils/logger.ts'
2+
3+
interface CacheEntry<T> {
4+
data: T
5+
expiresAt: number
6+
}
7+
8+
interface CacheOptions {
9+
ttlDays?: number
10+
}
11+
12+
export class CacheService {
13+
private logger: Logger
14+
private cache: Map<string, CacheEntry<any>>
15+
16+
constructor() {
17+
this.logger = initLogger()
18+
this.cache = new Map()
19+
}
20+
21+
/**
22+
* Fetches data with caching. If cached data exists and hasn't expired, returns it.
23+
* Otherwise, fetches fresh data using the provided fetcher function.
24+
*/
25+
async fetchWithCache<T>(
26+
key: string,
27+
fetcher: () => Promise<T>,
28+
options: CacheOptions = {}
29+
): Promise<T> {
30+
const { ttlDays = 7 } = options
31+
const now = Date.now()
32+
33+
// Check if we have valid cached data
34+
const cached = this.cache.get(key)
35+
if (cached && cached.expiresAt > now) {
36+
this.logger.info({ msg: `Cache hit for key: ${key}` })
37+
return cached.data
38+
}
39+
40+
// Cache miss or expired - fetch fresh data
41+
this.logger.info({ msg: `Cache miss for key: ${key}, fetching fresh data...` })
42+
try {
43+
const data = await fetcher()
44+
45+
// Calculate expiration time
46+
const expiresAt = now + (ttlDays * 24 * 60 * 60 * 1000)
47+
48+
// Store in cache
49+
this.cache.set(key, { data, expiresAt })
50+
51+
this.logger.info({
52+
msg: `Cached data for key: ${key}`,
53+
expiresAt: new Date(expiresAt).toISOString(),
54+
ttlDays
55+
})
56+
57+
return data
58+
} catch (error) {
59+
this.logger.error({ err: error, msg: `Failed to fetch data for key: ${key}` })
60+
throw error
61+
}
62+
}
63+
64+
/**
65+
* Convenience method for fetching HTTP resources with caching
66+
*/
67+
async fetchHttpWithCache(
68+
url: string,
69+
options: CacheOptions & { responseType?: 'json' | 'text' } = {}
70+
): Promise<any> {
71+
const { responseType = 'json', ...cacheOptions } = options
72+
73+
return this.fetchWithCache(
74+
url,
75+
async () => {
76+
this.logger.info({ msg: `Fetching HTTP resource: ${url}` })
77+
const response = await fetch(url)
78+
79+
if (!response.ok) {
80+
throw new Error(`HTTP error! status: ${response.status} ${response.statusText}`)
81+
}
82+
83+
const data = responseType === 'json' ? await response.json() : await response.text()
84+
this.logger.info({ msg: `Successfully fetched HTTP resource: ${url}` })
85+
return data
86+
},
87+
cacheOptions
88+
)
89+
}
90+
91+
/**
92+
* Clear a specific cache entry
93+
*/
94+
clearCache(key: string): void {
95+
this.cache.delete(key)
96+
this.logger.info({ msg: `Cleared cache for key: ${key}` })
97+
}
98+
99+
/**
100+
* Clear all expired cache entries
101+
*/
102+
clearExpiredCache(): void {
103+
const now = Date.now()
104+
let clearedCount = 0
105+
106+
for (const [key, entry] of this.cache.entries()) {
107+
if (entry.expiresAt <= now) {
108+
this.cache.delete(key)
109+
clearedCount++
110+
}
111+
}
112+
113+
if (clearedCount > 0) {
114+
this.logger.info({ msg: `Cleared ${clearedCount} expired cache entries` })
115+
}
116+
}
117+
118+
/**
119+
* Get cache statistics
120+
*/
121+
getCacheStats(): { size: number; keys: string[] } {
122+
return {
123+
size: this.cache.size,
124+
keys: Array.from(this.cache.keys())
125+
}
126+
}
127+
}

0 commit comments

Comments
 (0)