Skip to content

Commit 4d57049

Browse files
committed
add data loader layer and file-based cache system
1 parent 66100fb commit 4d57049

File tree

9 files changed

+165
-75
lines changed

9 files changed

+165
-75
lines changed

src/lib/utils/cacheAsyncFn.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import fs from "fs"
2+
import path from "path"
3+
4+
const CACHE_FILE_DIR = path.resolve(".next/cache")
5+
6+
/**
7+
* Caches the result of an asynchronous function to avoid multiple calls during build time.
8+
* This helps prevent hitting external API rate limits by storing the result in a file.
9+
*
10+
* @param key A unique identifier for the cached function result
11+
* @param fn The asynchronous function to be cached
12+
* @param options Optional parameters to configure cache behavior
13+
* @returns A new function that returns the cached result or executes the original function if the cache is invalid
14+
*
15+
* @example
16+
* const cachedFetch = cacheAsyncFn('uniqueKey', fetchSomething);
17+
*
18+
* await cachedFetch(); // Fetches and caches the data
19+
* await cachedFetch(); // Returns the cached data without re-fetching
20+
*
21+
* @note The cache is stored in the '.next/cache' directory and expires after the `cacheTimeout`
22+
*/
23+
24+
export function cacheAsyncFn<T>(
25+
key: string,
26+
fn: () => Promise<T>,
27+
options?: { cacheTimeout?: number }
28+
) {
29+
const cacheFilePath = path.resolve(CACHE_FILE_DIR, `${key}.json`)
30+
31+
return async (): Promise<T> => {
32+
let value: T | undefined
33+
34+
// Check if cache file exists
35+
if (fs.existsSync(cacheFilePath)) {
36+
const fileStats = fs.statSync(cacheFilePath)
37+
const now = Date.now()
38+
const cacheAge = now - new Date(fileStats.mtime).getTime()
39+
const isCacheExpired =
40+
options?.cacheTimeout && cacheAge > options.cacheTimeout
41+
42+
// Invalidate cache if it's too old
43+
if (isCacheExpired) {
44+
// Remove stale cache
45+
fs.unlinkSync(cacheFilePath)
46+
console.log("Stale cache removed", key)
47+
} else {
48+
// Cache hit
49+
const cachedData = fs.readFileSync(cacheFilePath, "utf-8")
50+
value = JSON.parse(cachedData)
51+
console.log("Cache hit", key)
52+
}
53+
}
54+
55+
// If no cached data, fetch fresh data
56+
if (!value) {
57+
console.log("Running function for the first time", key)
58+
value = await fn()
59+
console.log("Function ran and cached", key)
60+
61+
// Ensure cache folder exists
62+
fs.mkdirSync(CACHE_FILE_DIR, { recursive: true })
63+
64+
// Write data to cache file
65+
fs.writeFileSync(cacheFilePath, JSON.stringify(value), "utf-8")
66+
console.log("Function cached", key)
67+
}
68+
69+
return value!
70+
}
71+
}

src/lib/utils/dataLoader.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { cacheAsyncFn } from "./cacheAsyncFn"
2+
3+
type DataLoaderFunction<T> = () => Promise<T>
4+
5+
/**
6+
* Loads data from multiple asynchronous functions and caches the results.
7+
*
8+
* @param loaders An array of tuples, each containing a unique identifier and the asynchronous function to be cached
9+
* @returns A promise that resolves to an array of the results from the cached functions
10+
*
11+
* @example
12+
* const [ethPrice, totalEthStaked, totalValueLocked] = await dataLoader([
13+
* ['ethPrice', fetchEthPrice],
14+
* ['totalEthStaked', fetchTotalEthStaked],
15+
* ['totalValueLocked', fetchTotalValueLocked],
16+
* ]);
17+
*/
18+
19+
export async function dataLoader<T extends unknown[]>(
20+
loaders: {
21+
[K in keyof T]: [string, DataLoaderFunction<T[K]>]
22+
},
23+
cacheTimeout: number = 1000 * 60 * 60 // 1 hour
24+
): Promise<T> {
25+
const cachedLoaders = loaders.map(([key, loader]) => {
26+
const cachedLoader = cacheAsyncFn(key, loader, {
27+
cacheTimeout,
28+
})
29+
return async () => {
30+
try {
31+
return await cachedLoader()
32+
} catch (error) {
33+
console.error(`Error in dataLoader for key "${key}":`, error)
34+
throw error
35+
}
36+
}
37+
})
38+
39+
const results = await Promise.all(cachedLoaders.map((loader) => loader()))
40+
return results as T
41+
}

src/lib/utils/runOnlyOnce.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

src/pages/[...slug].tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ import mdComponents from "@/components/MdComponents"
2828
import PageMetadata from "@/components/PageMetadata"
2929

3030
import { getFileContributorInfo } from "@/lib/utils/contributors"
31+
import { dataLoader } from "@/lib/utils/dataLoader"
3132
import { dateToString } from "@/lib/utils/date"
3233
import { getLastDeployDate } from "@/lib/utils/getLastDeployDate"
3334
import { getContent, getContentBySlug } from "@/lib/utils/md"
34-
import { runOnlyOnce } from "@/lib/utils/runOnlyOnce"
3535
import { getLocaleTimestamp } from "@/lib/utils/time"
3636
import { remapTableOfContents } from "@/lib/utils/toc"
3737
import {
@@ -114,11 +114,6 @@ type Props = Omit<Parameters<LayoutMappingType[Layout]>[0], "children"> &
114114
gfissues: Awaited<ReturnType<typeof fetchGFIs>>
115115
}
116116

117-
// Fetch external API data once to avoid hitting rate limit
118-
const gfIssuesDataFetch = runOnlyOnce(async () => {
119-
return await fetchGFIs()
120-
})
121-
122117
const commitHistoryCache: CommitHistory = {}
123118

124119
export const getStaticProps = (async (context) => {
@@ -199,7 +194,7 @@ export const getStaticProps = (async (context) => {
199194
lastDeployDate
200195
)
201196

202-
const gfissues = await gfIssuesDataFetch()
197+
const [gfissues] = await dataLoader([["gfissues", fetchGFIs]])
203198

204199
return {
205200
props: {

src/pages/developers/local-environment.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import PageMetadata from "@/components/PageMetadata"
2121
import ProductCard from "@/components/ProductCard"
2222
import Translation from "@/components/Translation"
2323

24+
import { dataLoader } from "@/lib/utils/dataLoader"
2425
import { existsNamespace } from "@/lib/utils/existsNamespace"
2526
import { getLastDeployDate } from "@/lib/utils/getLastDeployDate"
26-
import { runOnlyOnce } from "@/lib/utils/runOnlyOnce"
2727
import { getLocaleTimestamp } from "@/lib/utils/time"
2828
import { getRequiredNamespacesForPage } from "@/lib/utils/translations"
2929

@@ -57,18 +57,16 @@ type Props = BasePageProps & {
5757
frameworksList: Framework[]
5858
}
5959

60-
const cachedFetchLocalEnvironmentFrameworkData = runOnlyOnce(
61-
getLocalEnvironmentFrameworkData
62-
)
63-
6460
export const getStaticProps = (async ({ locale }) => {
6561
const requiredNamespaces = getRequiredNamespacesForPage(
6662
"/developers/local-environment"
6763
)
6864

6965
const contentNotTranslated = !existsNamespace(locale!, requiredNamespaces[2])
7066

71-
const frameworksListData = await cachedFetchLocalEnvironmentFrameworkData()
67+
const [frameworksListData] = await dataLoader([
68+
["frameworksListData", getLocalEnvironmentFrameworkData],
69+
])
7270

7371
const lastDeployDate = getLastDeployDate()
7472
const lastDeployLocaleTimestamp = getLocaleTimestamp(

src/pages/index.tsx

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,12 @@ import {
5656
import WindowBox from "@/components/WindowBox"
5757

5858
import { cn } from "@/lib/utils/cn"
59+
import { dataLoader } from "@/lib/utils/dataLoader"
5960
import { isValidDate } from "@/lib/utils/date"
6061
import { existsNamespace } from "@/lib/utils/existsNamespace"
6162
import { getLastDeployDate } from "@/lib/utils/getLastDeployDate"
6263
import { trackCustomEvent } from "@/lib/utils/matomo"
6364
import { polishRSSList } from "@/lib/utils/rss"
64-
import { runOnlyOnce } from "@/lib/utils/runOnlyOnce"
6565
import { breakpointAsNumber } from "@/lib/utils/screen"
6666
import { getLocaleTimestamp } from "@/lib/utils/time"
6767
import { getRequiredNamespacesForPage } from "@/lib/utils/translations"
@@ -108,30 +108,49 @@ const Codeblock = lazy(() =>
108108

109109
const StatsBoxGrid = lazy(() => import("@/components/StatsBoxGrid"))
110110

111-
const cachedEthPrice = runOnlyOnce(fetchEthPrice)
112-
const cachedFetchTotalEthStaked = runOnlyOnce(fetchTotalEthStaked)
113-
const cachedFetchTotalValueLocked = runOnlyOnce(fetchTotalValueLocked)
114-
const cachedXmlBlogFeeds = runOnlyOnce(async () => await fetchRSS(BLOG_FEEDS))
115-
const cachedAttestantBlog = runOnlyOnce(fetchAttestantPosts)
116-
const cachedGrowThePieData = runOnlyOnce(fetchGrowThePie)
117-
const cachedFetchCommunityEvents = runOnlyOnce(fetchCommunityEvents)
111+
// API calls
112+
const fetchXmlBlogFeeds = async () => {
113+
return await fetchRSS(BLOG_FEEDS)
114+
}
118115

119116
type Props = BasePageProps & {
120117
metricResults: AllMetricData
121118
rssData: { rssItems: RSSItem[]; blogLinks: CommunityBlog[] }
122119
}
123120

121+
// In seconds
122+
const REVALIDATE_TIME = BASE_TIME_UNIT * 24
123+
124124
export const getStaticProps = (async ({ locale }) => {
125-
const growThePieData = await cachedGrowThePieData()
125+
const [
126+
ethPrice,
127+
totalEthStaked,
128+
totalValueLocked,
129+
growThePieData,
130+
communityEvents,
131+
attestantPosts,
132+
xmlBlogs,
133+
] = await dataLoader(
134+
[
135+
["ethPrice", fetchEthPrice],
136+
["totalEthStaked", fetchTotalEthStaked],
137+
["totalValueLocked", fetchTotalValueLocked],
138+
["growThePieData", fetchGrowThePie],
139+
["communityEvents", fetchCommunityEvents],
140+
["attestantPosts", fetchAttestantPosts],
141+
["rssData", fetchXmlBlogFeeds],
142+
],
143+
REVALIDATE_TIME * 1000
144+
)
145+
126146
const metricResults: AllMetricData = {
127-
ethPrice: await cachedEthPrice(),
128-
totalEthStaked: await cachedFetchTotalEthStaked(),
129-
totalValueLocked: await cachedFetchTotalValueLocked(),
147+
ethPrice,
148+
totalEthStaked,
149+
totalValueLocked,
130150
txCount: growThePieData.txCount,
131151
txCostsMedianUsd: growThePieData.txCostsMedianUsd,
132152
}
133153

134-
const communityEvents = await cachedFetchCommunityEvents()
135154
const calendar = communityEvents.upcomingEventData
136155
.sort((a, b) => {
137156
const dateA = isValidDate(a.date) ? new Date(a.date).getTime() : -Infinity
@@ -153,10 +172,8 @@ export const getStaticProps = (async ({ locale }) => {
153172
lastDeployDate
154173
)
155174

156-
// load RSS feed items
157-
const xmlBlogs = await cachedXmlBlogFeeds()
158-
const attestantBlog = await cachedAttestantBlog()
159-
const polishedRssItems = polishRSSList(attestantBlog, ...xmlBlogs)
175+
// RSS feed items
176+
const polishedRssItems = polishRSSList(attestantPosts, ...xmlBlogs)
160177
const rssItems = polishedRssItems.slice(0, RSS_DISPLAY_COUNT)
161178

162179
const blogLinks = polishedRssItems.map(({ source, sourceUrl }) => ({
@@ -174,7 +191,7 @@ export const getStaticProps = (async ({ locale }) => {
174191
metricResults,
175192
rssData: { rssItems, blogLinks },
176193
},
177-
revalidate: BASE_TIME_UNIT * 24,
194+
revalidate: REVALIDATE_TIME,
178195
}
179196
}) satisfies GetStaticProps<Props>
180197

src/pages/stablecoins.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ import Translation from "@/components/Translation"
3838
import { Divider } from "@/components/ui/divider"
3939

4040
import { cn } from "@/lib/utils/cn"
41+
import { dataLoader } from "@/lib/utils/dataLoader"
4142
import { existsNamespace } from "@/lib/utils/existsNamespace"
4243
import { getLastDeployDate } from "@/lib/utils/getLastDeployDate"
43-
import { runOnlyOnce } from "@/lib/utils/runOnlyOnce"
4444
import { getLocaleTimestamp } from "@/lib/utils/time"
4545
import { getRequiredNamespacesForPage } from "@/lib/utils/translations"
4646

@@ -90,10 +90,6 @@ type Props = BasePageProps & {
9090
marketsHasError: boolean
9191
}
9292

93-
// Fetch external API data once to avoid hitting rate limit
94-
const ethereumEcosystemDataFetch = runOnlyOnce(fetchEthereumEcosystemData)
95-
const ethereumStablecoinsDataFetch = runOnlyOnce(fetchEthereumStablecoinsData)
96-
9793
export const getStaticProps = (async ({ locale }) => {
9894
const lastDeployDate = getLastDeployDate()
9995
const lastDeployLocaleTimestamp = getLocaleTimestamp(
@@ -139,12 +135,12 @@ export const getStaticProps = (async ({ locale }) => {
139135
}
140136

141137
try {
142-
// Fetch token data in the Ethereum ecosystem
143-
const ethereumEcosystemData: EthereumDataResponse =
144-
await ethereumEcosystemDataFetch()
145-
// Fetch token data for stablecoins
146-
const stablecoinsData: StablecoinDataResponse =
147-
await ethereumStablecoinsDataFetch()
138+
const [ethereumEcosystemData, stablecoinsData] = await dataLoader<
139+
[EthereumDataResponse, StablecoinDataResponse]
140+
>([
141+
["ethereumEcosystemData", fetchEthereumEcosystemData],
142+
["ethereumStablecoinsData", fetchEthereumStablecoinsData],
143+
])
148144

149145
// Get the intersection of stablecoins and Ethereum tokens to only have a list of data for stablecoins in the Ethereum ecosystem
150146
const ethereumStablecoinData = stablecoinsData.filter(

src/pages/staking/index.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ import InlineLink from "@/components/ui/Link"
3434
import { ListItem, UnorderedList } from "@/components/ui/list"
3535

3636
import { cn } from "@/lib/utils/cn"
37+
import { dataLoader } from "@/lib/utils/dataLoader"
3738
import { existsNamespace } from "@/lib/utils/existsNamespace"
3839
import { getLastDeployDate } from "@/lib/utils/getLastDeployDate"
39-
import { runOnlyOnce } from "@/lib/utils/runOnlyOnce"
4040
import { getLocaleTimestamp } from "@/lib/utils/time"
4141
import { getRequiredNamespacesForPage } from "@/lib/utils/translations"
4242

@@ -149,8 +149,6 @@ const fetchBeaconchainData = async (): Promise<StakingStatsData> => {
149149
return { totalEthStaked, validatorscount, apr }
150150
}
151151

152-
const cachedFetchBeaconchainData = runOnlyOnce(fetchBeaconchainData)
153-
154152
type Props = BasePageProps & {
155153
data: StakingStatsData
156154
}
@@ -166,7 +164,7 @@ export const getStaticProps = (async ({ locale }) => {
166164

167165
const contentNotTranslated = !existsNamespace(locale!, requiredNamespaces[2])
168166

169-
const data = await cachedFetchBeaconchainData()
167+
const [data] = await dataLoader([["stakingStatsData", fetchBeaconchainData]])
170168

171169
return {
172170
props: {

0 commit comments

Comments
 (0)