UNPKG

@hideart/react-native-image-cache

Version:

React Native image file system caching for iOS and Android

274 lines (236 loc) 7.81 kB
import SHA1 from 'crypto-js/sha1'; import { v4 as uniqueId } from 'uuid'; import { FileStat, FileSystem } from 'react-native-file-access'; import { Config, DownloadOptions } from './types'; import defaultConfiguration from './defaultConfiguration'; import FastImage from '@d11/react-native-fast-image'; // Blow away FastImage cache so that updated assets are loaded. // (We could blow away FastImage at any time since we use FileSystem for the actual caching) // TODO: figure out how to prevent FastImage from caching at all. FastImage.clearDiskCache(); FastImage.clearMemoryCache(); interface CacheEntryItem { exists: boolean; path: string; tmpPath: string; } export class CacheEntry { source: string; options: DownloadOptions | undefined; downloadPromise: Promise<string | undefined> | undefined; noCache: boolean | undefined = false; maxAge: number | undefined; constructor( source: string, options: DownloadOptions | undefined, noCache?: boolean, maxAge?: number ) { this.noCache = noCache; this.options = options; this.source = source; this.maxAge = maxAge; } async getPath(): Promise<string | undefined> { const { source, maxAge, noCache } = this; const { exists, path, tmpPath } = await getCacheEntry(source, maxAge); if (exists && !noCache) { try { const { lastModified } = await FileSystem.stat(path); const ageInSeconds = (Date.now() - lastModified) / 1000; const ageInHours = ageInSeconds / (60 * 60); // Download fresh images every day if (ageInHours > CacheManager.config.refreshIntervalHours) { // Skip fresh download if one is already in flight if (!this.downloadPromise) { this.downloadPromise = this.download(path, tmpPath); } } } catch (err) { // error logging } return path; } if (!this.downloadPromise) { this.downloadPromise = this.download(path, tmpPath); } try { await this.downloadPromise; } catch (err) { // noop if file now exists const exists = await FileSystem.exists(path); if (!exists) throw err; } return path; } private async download(path: string, tmpPath: string): Promise<string> { const { source, options, noCache } = this; try { // if noCache is true then return the source uri without caching it if (noCache) { return source; } const result = await FileSystem.fetch(source, { path: tmpPath, ...options, }); // If the image download failed, we don't cache anything if (result && result.status !== 200) { throw new Error( `Image download failed with status: ${result.status}, ${source}` ); } // This mv may throw if we download many copies of the same image. // (eg. feed downloads may copies of a pfp chip) await FileSystem.mv(tmpPath, path); if (CacheManager.config.cacheLimit) { await CacheManager.pruneCache(); } return path; } catch (err) { // disabled for now, nothing actionable // appException('CacheManager: download', err, { source, path }) throw err; } finally { this.downloadPromise = undefined; } } } export default class CacheManager { static defaultConfig: Config = defaultConfiguration; static config: Config; get config() { return CacheManager.defaultConfig; } set config(newConfig) { CacheManager.defaultConfig = newConfig; } static entries: { [uri: string]: CacheEntry } = {}; static get( source: string, options: DownloadOptions | undefined, noCache?: boolean, maxAge?: number ): CacheEntry { if (!CacheManager.entries[source]) { CacheManager.entries[source] = new CacheEntry( source, options, noCache, maxAge ); return CacheManager.entries[source]; } return CacheManager.entries[source]; } static async clearCache(): Promise<void> { const files = await FileSystem.ls(CacheManager.config.baseDir); for (const file of files) { try { await FileSystem.unlink(`${CacheManager.config.baseDir}${file}`); } catch (e) { console.log(`error while clearing images cache, error: ${e}`); } } } static async removeCacheEntry(entry: string): Promise<void> { try { FastImage.clearDiskCache(); FastImage.clearMemoryCache(); const file = await getCacheEntry(entry); const { path } = file; await FileSystem.unlink(path); } catch (e) { throw new Error('error while clearing image from cache'); } } static async getCacheSize(): Promise<number> { const result = await FileSystem.stat(CacheManager.config.baseDir); if (!result) { throw new Error(`${CacheManager.config.baseDir} not found`); } return result.size; } static async isImageCached(entry: string): Promise<boolean> { try { const file = await getCacheEntry(entry); const { exists } = file; return exists; } catch (e) { throw new Error('Error while checking if image already exists on cache'); } } static prefetch(source: string | string[], options?: DownloadOptions): void { if (typeof source === 'string') { CacheManager.get(source, options).getPath(); } else { source.map(image => { CacheManager.get(image, options).getPath(); }); } } static async pruneCache() { // If cache directory does not exist yet there's no need for pruning. if (!(await CacheManager.getCacheSize())) { return; } const files = await FileSystem.statDir(CacheManager.config.baseDir); files.sort((a: FileStat, b: FileStat) => { return a.lastModified - b.lastModified; }); const currentCacheSize = files.reduce((cacheSize, file: FileStat) => { return cacheSize + file.size; }, 0); if (currentCacheSize > CacheManager.config.cacheLimit) { let overflowSize = currentCacheSize - CacheManager.config.cacheLimit; while (overflowSize > 0 && files.length) { const file = files.shift(); if (file) { if (await FileSystem.exists(file.path)) { overflowSize = overflowSize - file.size; await FileSystem.unlink(file.path).catch(e => console.log(e)); } } } } } } const getCacheEntry = async ( cacheKey: string, maxAge?: number | undefined ): Promise<CacheEntryItem> => { let newCacheKey = cacheKey; if (CacheManager.config.getCustomCacheKey) { newCacheKey = CacheManager.config.getCustomCacheKey(cacheKey); } const filename = cacheKey.substring( cacheKey.lastIndexOf('/'), cacheKey.indexOf('?') === -1 ? cacheKey.length : cacheKey.indexOf('?') ); const ext = filename.indexOf('.') === -1 ? '.webp' : filename.substring(filename.lastIndexOf('.')); const sha = SHA1(newCacheKey); const path = `${CacheManager.config.baseDir}${sha}${ext}`; const tmpPath = `${CacheManager.config.baseDir}${sha}-${uniqueId()}${ext}`; let exists = false; try { const cacheDirExists = await FileSystem.exists(CacheManager.config.baseDir); if (!cacheDirExists) { await FileSystem.mkdir(CacheManager.config.baseDir); } exists = await FileSystem.exists(path); if (maxAge && exists) { const { lastModified } = await FileSystem.stat(path); const ageInHours = Math.floor(Date.now() - lastModified) / 1000 / 3600; if (maxAge < ageInHours) { await FileSystem.unlink(path); return { exists: false, path, tmpPath }; } } } catch (err) { // disabled for now, nothing actionable } return { exists, path, tmpPath }; };