/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict-local * @format * @oncall react_native */ import type { BuildParameters, CacheData, CacheManager, CacheManagerFactoryOptions, CacheManagerWriteOptions, } from '../flow-types'; import rootRelativeCacheKeys from '../lib/rootRelativeCacheKeys'; import {promises as fsPromises} from 'fs'; import {tmpdir} from 'os'; import path from 'path'; import {Timeout, clearTimeout, setTimeout} from 'timers'; import {deserialize, serialize} from 'v8'; // eslint-disable-next-line import/no-commonjs const debug = require('debug')('Metro:FileMapCache'); type AutoSaveOptions = $ReadOnly<{ debounceMs: number, }>; type DiskCacheConfig = $ReadOnly<{ autoSave?: Partial | boolean, cacheFilePrefix?: ?string, cacheDirectory?: ?string, }>; const DEFAULT_PREFIX = 'metro-file-map'; const DEFAULT_DIRECTORY = tmpdir(); const DEFAULT_AUTO_SAVE_DEBOUNCE_MS = 5000; export class DiskCacheManager implements CacheManager { +#autoSaveOpts: ?AutoSaveOptions; +#cachePath: string; #debounceTimeout: ?Timeout = null; #writePromise: Promise = Promise.resolve(); #hasUnwrittenChanges: boolean = false; #tryWrite: ?() => Promise; #stopListening: ?() => void; constructor( {buildParameters}: CacheManagerFactoryOptions, {autoSave = {}, cacheDirectory, cacheFilePrefix}: DiskCacheConfig, ) { this.#cachePath = DiskCacheManager.getCacheFilePath( buildParameters, cacheFilePrefix, cacheDirectory, ); // Normalise auto-save options. if (autoSave) { const {debounceMs = DEFAULT_AUTO_SAVE_DEBOUNCE_MS} = autoSave === true ? {} : autoSave; this.#autoSaveOpts = {debounceMs}; } } static getCacheFilePath( buildParameters: BuildParameters, cacheFilePrefix?: ?string, cacheDirectory?: ?string, ): string { const {rootDirHash, relativeConfigHash} = rootRelativeCacheKeys(buildParameters); return path.join( cacheDirectory ?? DEFAULT_DIRECTORY, `${ cacheFilePrefix ?? DEFAULT_PREFIX }-${rootDirHash}-${relativeConfigHash}`, ); } getCacheFilePath(): string { return this.#cachePath; } async read(): Promise { try { return deserialize(await fsPromises.readFile(this.#cachePath)); } catch (e) { if (e?.code === 'ENOENT') { // Cache file not found - not considered an error. return null; } // Rethrow anything else. throw e; } } async write( getSnapshot: () => CacheData, { changedSinceCacheRead, eventSource, onWriteError, }: CacheManagerWriteOptions, ): Promise { // Initialise a writer function using a promise queue to ensure writes are // sequenced. const tryWrite = (this.#tryWrite = () => { this.#writePromise = this.#writePromise .then(async () => { if (!this.#hasUnwrittenChanges) { return; } const data = getSnapshot(); this.#hasUnwrittenChanges = false; await fsPromises.writeFile(this.#cachePath, serialize(data)); debug('Written cache to %s', this.#cachePath); }) .catch(onWriteError); return this.#writePromise; }); // Set up auto-save on changes, if enabled. if (this.#autoSaveOpts) { const autoSave = this.#autoSaveOpts; this.#stopListening?.(); this.#stopListening = eventSource.onChange(() => { this.#hasUnwrittenChanges = true; if (this.#debounceTimeout) { this.#debounceTimeout.refresh(); } else { this.#debounceTimeout = setTimeout( () => tryWrite(), autoSave.debounceMs, ).unref(); } }); } // Write immediately if state has changed since the cache was read. if (changedSinceCacheRead) { this.#hasUnwrittenChanges = true; await tryWrite(); } } async end() { // Clear any timers if (this.#debounceTimeout) { clearTimeout(this.#debounceTimeout); } // Remove event listeners this.#stopListening?.(); // Flush unwritten changes to disk (no-op if no changes) await this.#tryWrite?.(); } }