diff --git a/packages/metro-cache/package.json b/packages/metro-cache/package.json new file mode 100644 index 00000000..b594a480 --- /dev/null +++ b/packages/metro-cache/package.json @@ -0,0 +1,14 @@ +{ + "version": "0.25.1", + "name": "metro-cache", + "description": "🚇 Cache layers for Metro", + "main": "src/index.js", + "repository": { + "type": "git", + "url": "git@github.com:facebook/metro.git" + }, + "scripts": { + "prepare-release": "test -d build && rm -rf src.real && mv src src.real && mv build src", + "cleanup-release": "test ! -e build && mv src build && mv src.real src" + } +} diff --git a/packages/metro-cache/src/Cache.js b/packages/metro-cache/src/Cache.js new file mode 100644 index 00000000..a8732b3f --- /dev/null +++ b/packages/metro-cache/src/Cache.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2018-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +import type {CacheStore} from 'metro-cache'; + +class Cache { + _stores: $ReadOnlyArray; + + constructor(stores: $ReadOnlyArray) { + this._stores = stores; + } + + async get(key: Buffer): Promise { + const stores = this._stores; + const length = stores.length; + + for (let i = 0; i < length; i++) { + let value = stores[i].get(key); + + if (value instanceof Promise) { + value = await value; + } + + if (value != null) { + return value; + } + } + + return null; + } + + set(key: Buffer, value: mixed): void { + Promise.all(this._stores.map(store => store.set(key, value))).catch(err => { + process.nextTick(() => { + throw err; + }); + }); + } +} + +module.exports = Cache; diff --git a/packages/metro-cache/src/__tests__/Cache.test.js b/packages/metro-cache/src/__tests__/Cache.test.js new file mode 100644 index 00000000..c74e7b66 --- /dev/null +++ b/packages/metro-cache/src/__tests__/Cache.test.js @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2018-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+javascript_foundation + * @format + */ + +'use strict'; + +const Cache = require('../Cache'); + +describe('Cache', () => { + function createStore(i) { + return { + name: 'store' + i, + get: jest.fn().mockImplementation(() => null), + set: jest.fn(), + }; + } + + it('returns null when no result is found', async () => { + const store1 = createStore(); + const store2 = createStore(); + const cache = new Cache([store1, store2]); + + // Calling a wrapped method. + const result = await cache.get('arg'); + + expect(result).toBe(null); + expect(store1.get).toHaveBeenCalledTimes(1); + expect(store2.get).toHaveBeenCalledTimes(1); + }); + + it('sequentially searches up until it finds a valid result', async () => { + const store1 = createStore(1); + const store2 = createStore(2); + const store3 = createStore(3); + const cache = new Cache([store1, store2, store3]); + + // Only cache 2 can return results. + store2.get.mockImplementation(() => 'foo'); + + const result = await cache.get('arg'); + + expect(result).toBe('foo'); + expect(store1.get).toHaveBeenCalledTimes(1); + expect(store2.get).toHaveBeenCalledTimes(1); + expect(store3.get).not.toHaveBeenCalled(); + }); + + it('awaits for promises on stores, even if they return undefined', async () => { + let resolve; + + const store1 = createStore(); + const store2 = createStore(); + const promise = new Promise((res, rej) => (resolve = res)); + const cache = new Cache([store1, store2]); + + store1.get.mockImplementation(() => promise); + cache.get('foo'); + + // Store 1 returns a promise, so store 2 is not called until it resolves. + expect(store1.get).toHaveBeenCalledTimes(1); + expect(store2.get).not.toHaveBeenCalled(); + + resolve(undefined); + await promise; + + expect(store1.get).toHaveBeenCalledTimes(1); + expect(store2.get).toHaveBeenCalledTimes(1); + }); + + it('throws on a buggy store', async () => { + jest.useFakeTimers(); + + const store1 = createStore(); + const store2 = createStore(); + const cache = new Cache([store1, store2]); + + let err1 = null; + let err2 = null; + + // Try sets. + store1.set.mockImplementation(() => Promise.reject(new RangeError('foo'))); + store2.set.mockImplementation(() => null); + + expect(() => cache.set('arg')).not.toThrow(); // Async throw. + + try { + jest.runAllTimers(); // Advancing the timer will make the cache throw. + } catch (err) { + err1 = err; + } + + expect(err1).toBeInstanceOf(RangeError); + + // Try gets. + store1.get.mockImplementation(() => Promise.reject(new TypeError('bar'))); + store2.get.mockImplementation(() => null); + + try { + await cache.get('arg'); + } catch (err) { + err2 = err; + } + + expect(err2).toBeInstanceOf(TypeError); + }); +}); diff --git a/packages/metro-cache/src/__tests__/stableHash.js b/packages/metro-cache/src/__tests__/stableHash.js new file mode 100644 index 00000000..9e5d173d --- /dev/null +++ b/packages/metro-cache/src/__tests__/stableHash.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2018-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+javascript_foundation + * @format + */ + +'use strict'; + +const stableHash = require('../stableHash'); + +describe('stableHash', () => { + it('ensures that the hash implementation supports switched order properties', () => { + const sortedHash = stableHash({ + a: 3, + b: 4, + c: { + d: 'd', + e: 'e', + }, + }); + + const unsortedHash = stableHash({ + b: 4, + c: { + e: 'e', + d: 'd', + }, + a: 3, + }); + + expect(unsortedHash).toEqual(sortedHash); + }); +}); diff --git a/packages/metro-cache/src/index.js b/packages/metro-cache/src/index.js new file mode 100644 index 00000000..06e14755 --- /dev/null +++ b/packages/metro-cache/src/index.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2018-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +const Cache = require('./Cache'); + +export type {CacheStore} from './types.flow'; + +module.exports.Cache = Cache; diff --git a/packages/metro-cache/src/stableHash.js b/packages/metro-cache/src/stableHash.js new file mode 100644 index 00000000..5feea2bd --- /dev/null +++ b/packages/metro-cache/src/stableHash.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2018-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +const crypto = require('crypto'); + +function canonicalize(key: string, value: mixed): mixed { + if (!(value instanceof Object) || value instanceof Array) { + return value; + } + + const keys = Object.keys(value).sort(); + const length = keys.length; + const object = {}; + + for (let i = 0; i < length; i++) { + object[keys[i]] = value[keys[i]]; + } + + return object; +} + +function stableHash(value: mixed) { + return crypto + .createHash('md5') + .update(JSON.stringify(value, canonicalize)) + .digest(); +} + +module.exports = stableHash; diff --git a/packages/metro-cache/src/types.flow.js b/packages/metro-cache/src/types.flow.js new file mode 100644 index 00000000..ca6c34a6 --- /dev/null +++ b/packages/metro-cache/src/types.flow.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2018-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +export type CacheStore = { + get(key: Buffer): ?mixed | Promise, + set(key: Buffer, value: mixed): void | Promise, +}; diff --git a/packages/metro/src/Config.js b/packages/metro/src/Config.js index cdeba261..adaf14ad 100644 --- a/packages/metro/src/Config.js +++ b/packages/metro/src/Config.js @@ -22,12 +22,21 @@ import type { import type {PostProcessModules} from './DeltaBundler'; import type {DynamicRequiresBehavior} from './ModuleGraph/worker/collectDependencies'; import type {IncomingMessage, ServerResponse} from 'http'; +import type {CacheStore} from 'metro-cache'; type Middleware = (IncomingMessage, ServerResponse, ?() => mixed) => mixed; export type ConfigT = { + // TODO: Remove this option below (T23793920) + assetTransforms?: boolean, + assetRegistryPath: string, + /** + * List of all store caches. + */ + cacheStores: Array, + /** * Can be used to generate a key that will invalidate the whole metro cache * (for example a global dependency version used by the transformer). @@ -50,8 +59,7 @@ export type ConfigT = { * from here and use `require('./fonts/example.ttf')` inside your app. */ getAssetExts: () => Array, - // TODO: Remove this option below (T23793920) - assetTransforms?: boolean, + /** * Returns a regular expression for modules that should be ignored by the * packager on a given platform. @@ -157,6 +165,7 @@ const DEFAULT = ({ enhanceMiddleware: middleware => middleware, extraNodeModules: {}, assetTransforms: false, + cacheStores: [], cacheVersion: '1.0', dynamicDepsInPackages: 'throwAtRuntime', getAssetExts: () => [], diff --git a/packages/metro/src/JSTransformer/__tests__/Transformer-test.js b/packages/metro/src/JSTransformer/__tests__/Transformer-test.js index a848c424..f58c13d2 100644 --- a/packages/metro/src/JSTransformer/__tests__/Transformer-test.js +++ b/packages/metro/src/JSTransformer/__tests__/Transformer-test.js @@ -15,7 +15,7 @@ const defaults = require('../../defaults'); const {Readable} = require('stream'); describe('Transformer', function() { - let api, Cache; + let api; const fileName = '/an/arbitrary/file.js'; const localPath = 'arbitrary/file.js'; const transformModulePath = __filename; @@ -37,9 +37,6 @@ describe('Transformer', function() { .mock('temp', () => ({path: () => '/arbitrary/path'})) .mock('jest-worker', () => ({__esModule: true, default: jest.fn()})); - Cache = jest.fn(); - Cache.prototype.get = jest.fn((a, b, c) => c()); - const fs = require('fs'); const jestWorker = require('jest-worker');