Files
flipper/desktop/static/compilePlugins.ts
Anton Nikolaev 452c52c291 Add internal plugins as workspaces to single package.json
Summary: We cannot just add internal plugins as workspaces to the root package.json in "sonar/desktop" as they are not open-sourced, so public build will break. Instead, I have created root package.json for internal plugins and added all internal plugins as workspaces there. This means all these plugins will use the single root yarn.lock and installation of their dependencies will be faster. This also means that plugins can declare dependencies to other local packages included into workspaces and they will be symlinked automatically.

Reviewed By: mweststrate

Differential Revision: D20806237

fbshipit-source-id: f8b3327166963dec7da8ac74079682aebe4527e1
2020-04-14 07:20:38 -07:00

338 lines
10 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();
let metroDir: string | undefined;
const metroDirPromise = getMetroDir().then((dir) => (metroDir = dir));
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 json = JSON.parse(packageJSON);
if (json.workspaces) {
return null;
}
const pkg = json 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 getMetroDir() {
let dir = __dirname;
while (true) {
const dirToCheck = path.join(dir, 'node_modules', 'metro');
if (await fs.pathExists(dirToCheck)) return dirToCheck;
const nextDir = path.dirname(dir);
if (!nextDir || nextDir === '' || nextDir === dir) {
break;
}
dir = nextDir;
}
return __dirname;
}
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: [metroDir || (await metroDirPromise)].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;
}
}
}