Files
flipper/desktop/static/compilePlugins.ts
Anton Nikolaev da7449c20b Enable Metro caching
Summary:
Enabling Metro cache for dev mode. For release builds we reset the cache.

Cache is used for faster compilation in dev mode for both main and renderer bundles, as well as for plugins.

Currently we have few side effects based on env vars, so cache is invalidated when they are changed. Also the cache is invalidated when transformations are changed (changed code, bumped dependency etc). Also added a script to reset the cache if something is going wrong.

Reviewed By: mweststrate

Differential Revision: D20691464

fbshipit-source-id: 478947d438bd3090f052dbfa6ad5c649523ecacb
2020-03-30 09:28:35 -07:00

316 lines
9.5 KiB
TypeScript

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow strict-local
*/
import path from 'path';
import fs from 'fs-extra';
import Metro from 'metro';
import util from 'util';
import recursiveReaddir from 'recursive-readdir';
import expandTilde from 'expand-tilde';
import pMap from 'p-map';
import {homedir} from 'os';
import Watchman from './watchman';
import getWatchFolders from './get-watch-folders';
const HOME_DIR = homedir();
const DEFAULT_COMPILE_OPTIONS: CompileOptions = {
force: false,
failSilently: true,
recompileOnChanges: true,
};
export type CompileOptions = {
force: boolean;
failSilently: boolean;
recompileOnChanges: boolean;
};
export type PluginManifest = {
version: string;
name: string;
main?: string;
bundleMain?: string;
[key: string]: any;
};
type PluginInfo = {
rootDir: string;
name: string;
entry: string;
manifest: PluginManifest;
};
export type CompiledPluginInfo = PluginManifest & {out: string};
export default async function (
reloadCallback: (() => void) | null,
pluginPaths: string[],
pluginCache: string,
options: CompileOptions = DEFAULT_COMPILE_OPTIONS,
) {
options = Object.assign({}, DEFAULT_COMPILE_OPTIONS, options);
const plugins = pluginEntryPoints(pluginPaths);
if (!(await fs.pathExists(pluginCache))) {
await fs.mkdir(pluginCache);
}
if (options.recompileOnChanges) {
await startWatchChanges(plugins, reloadCallback, pluginCache, options);
}
const compilations = pMap(
Object.values(plugins),
(plugin) => {
return compilePlugin(plugin, pluginCache, options);
},
{concurrency: 4},
);
const dynamicPlugins = (await compilations).filter(
(c) => c !== null,
) as CompiledPluginInfo[];
console.log('✅ Compiled all plugins.');
return dynamicPlugins;
}
async function startWatchingPluginsUsingWatchman(
plugins: PluginInfo[],
onPluginChanged: (plugin: PluginInfo) => void,
) {
// Initializing a watchman for each folder containing plugins
const watchmanRootMap: {[key: string]: Watchman} = {};
await Promise.all(
plugins.map(async (plugin) => {
const watchmanRoot = path.resolve(plugin.rootDir, '..');
if (!watchmanRootMap[watchmanRoot]) {
watchmanRootMap[watchmanRoot] = new Watchman(watchmanRoot);
await watchmanRootMap[watchmanRoot].initialize();
}
}),
);
// Start watching plugins using the initialized watchmans
await Promise.all(
plugins.map(async (plugin) => {
const watchmanRoot = path.resolve(plugin.rootDir, '..');
const watchman = watchmanRootMap[watchmanRoot];
await watchman.startWatchFiles(
path.relative(watchmanRoot, plugin.rootDir),
() => onPluginChanged(plugin),
{
excludes: ['**/__tests__/**/*', '**/node_modules/**/*', '**/.*'],
},
);
}),
);
}
async function startWatchChanges(
plugins: {[key: string]: PluginInfo},
reloadCallback: (() => void) | null,
pluginCache: string,
options: CompileOptions = DEFAULT_COMPILE_OPTIONS,
) {
// eslint-disable-next-line no-console
console.log('🕵️‍ Watching for plugin changes');
const delayedCompilation: {[key: string]: NodeJS.Timeout | null} = {};
const kCompilationDelayMillis = 1000;
const onPluginChanged = (plugin: PluginInfo) => {
if (!delayedCompilation[plugin.name]) {
delayedCompilation[plugin.name] = setTimeout(() => {
delayedCompilation[plugin.name] = null;
// eslint-disable-next-line no-console
console.log(`🕵️‍ Detected changes in ${plugin.name}`);
const watchOptions = Object.assign(options, {force: true});
compilePlugin(plugin, pluginCache, watchOptions).then(
reloadCallback ?? (() => {}),
);
}, kCompilationDelayMillis);
}
};
const filteredPlugins = Object.values(plugins)
// no hot reloading for plugins in .flipper folder. This is to prevent
// Flipper from reloading, while we are doing changes on thirdparty plugins.
.filter(
(plugin) => !plugin.rootDir.startsWith(path.join(HOME_DIR, '.flipper')),
);
try {
await startWatchingPluginsUsingWatchman(filteredPlugins, onPluginChanged);
} catch (err) {
console.error(
'Failed to start watching plugin files using Watchman, continue without hot reloading',
err,
);
}
}
function hash(string: string) {
let hash = 0;
if (string.length === 0) {
return hash;
}
let chr;
for (let i = 0; i < string.length; i++) {
chr = string.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0;
}
return hash;
}
const fileToIdMap = new Map();
const createModuleIdFactory = () => (filePath: string) => {
if (filePath === '__prelude__') {
return 0;
}
let id = fileToIdMap.get(filePath);
if (typeof id !== 'number') {
id = hash(filePath);
fileToIdMap.set(filePath, id);
}
return id;
};
function pluginEntryPoints(additionalPaths: string[] = []) {
const defaultPluginPath = path.join(HOME_DIR, '.flipper', 'node_modules');
const entryPoints = entryPointForPluginFolder(defaultPluginPath);
if (typeof additionalPaths === 'string') {
additionalPaths = [additionalPaths];
}
additionalPaths.forEach((additionalPath) => {
const additionalPlugins = entryPointForPluginFolder(additionalPath);
Object.keys(additionalPlugins).forEach((key) => {
entryPoints[key] = additionalPlugins[key];
});
});
return entryPoints;
}
function entryPointForPluginFolder(pluginPath: string) {
pluginPath = expandTilde(pluginPath);
if (!fs.existsSync(pluginPath)) {
return {};
}
return fs
.readdirSync(pluginPath)
.filter((name) => fs.lstatSync(path.join(pluginPath, name)).isDirectory())
.filter(Boolean)
.map((name) => {
let packageJSON;
try {
packageJSON = fs
.readFileSync(path.join(pluginPath, name, 'package.json'))
.toString();
} catch (e) {}
if (packageJSON) {
try {
const pkg = JSON.parse(packageJSON) as PluginManifest;
const plugin: PluginInfo = {
manifest: pkg,
name: pkg.name,
entry: path.join(pluginPath, name, pkg.main || 'index.js'),
rootDir: path.join(pluginPath, name),
};
return plugin;
} catch (e) {
console.error(
`Could not load plugin "${pluginPath}", because package.json is invalid.`,
);
console.error(e);
return null;
}
}
return null;
})
.filter(Boolean)
.reduce<{[key: string]: PluginInfo}>((acc, cv) => {
acc[cv!.name] = cv!;
return acc;
}, {});
}
async function mostRecentlyChanged(dir: string) {
const files = await util.promisify<string, string[]>(recursiveReaddir)(dir);
return files
.map((f) => fs.lstatSync(f).ctime)
.reduce((a, b) => (a > b ? a : b), new Date(0));
}
async function compilePlugin(
pluginInfo: PluginInfo,
pluginCache: string,
{force, failSilently}: CompileOptions,
): Promise<CompiledPluginInfo | null> {
const {rootDir, manifest, entry, name} = pluginInfo;
const bundleMain = manifest.bundleMain ?? path.join('dist', 'index.js');
const bundlePath = path.join(rootDir, bundleMain);
const dev = process.env.NODE_ENV !== 'production';
if (await fs.pathExists(bundlePath)) {
// eslint-disable-next-line no-console
const out = path.join(rootDir, bundleMain);
console.log(`🥫 Using pre-built version of ${name}: ${out}...`);
return Object.assign({}, pluginInfo.manifest, {out});
} else {
const out = path.join(
pluginCache,
`${name}@${manifest.version || '0.0.0'}.js`,
);
const result = Object.assign({}, pluginInfo.manifest, {out});
const rootDirCtime = await mostRecentlyChanged(rootDir);
if (
!force &&
(await fs.pathExists(out)) &&
rootDirCtime < (await fs.lstat(out)).ctime
) {
// eslint-disable-next-line no-console
console.log(`🥫 Using cached version of ${name}...`);
return result;
} else {
// eslint-disable-line no-console
console.log(`⚙️ Compiling ${name}...`);
try {
await Metro.runBuild(
{
reporter: {update: () => {}},
projectRoot: rootDir,
watchFolders: [__dirname].concat(await getWatchFolders(rootDir)),
serializer: {
getRunModuleStatement: (moduleID: string) =>
`module.exports = global.__r(${moduleID}).default;`,
createModuleIdFactory,
},
transformer: {
babelTransformerPath: global.electronResolve
? global.electronResolve('flipper-babel-transformer') // when compilation is executing in Electron main process
: require.resolve('flipper-babel-transformer'), // when compilation is is executing in Node.js script
},
resolver: {
sourceExts: ['tsx', 'ts', 'js'],
blacklistRE: /\.native\.js$/,
},
},
{
entry: entry.replace(rootDir, '.'),
out,
dev,
sourceMap: true,
minify: false,
resetCache: !dev,
},
);
} catch (e) {
if (failSilently) {
console.error(
`❌ Plugin ${name} is ignored, because it could not be compiled.`,
);
console.error(e);
return null;
} else {
throw e;
}
}
return result;
}
}
}