Dev mode: fixed loading plugins located outside of the Flipper source root folder

Summary: Dev mode: fixed loading of plugins located outside of the Flipper source root folder, e.g. in ~/flipper-plugins as suggested in tutorial docs.

Reviewed By: passy

Differential Revision: D21306639

fbshipit-source-id: bb9044b25324065f0c12169b95fbe663da8d4305
This commit is contained in:
Anton Nikolaev
2020-04-30 04:25:28 -07:00
committed by Facebook GitHub Bot
parent b27f8ee236
commit c21ccedf14
11 changed files with 172 additions and 45 deletions

3
.vscode/launch.json vendored
View File

@@ -20,8 +20,9 @@
"type": "node",
"request": "launch",
"name": "Launch Current Jest Suite",
"cwd": "${workspaceFolder}/desktop",
"program": "${workspaceFolder}/desktop/node_modules/.bin/jest",
"args": ["--runInBand", "${relativeFile}"]
"args": ["--runInBand", "${file}"]
},
{
"type": "node",

View File

@@ -10,7 +10,7 @@
import {parse} from '@babel/parser';
import {transformFromAstSync} from '@babel/core';
import {default as generate} from '@babel/generator';
const flipperRequires = require('../flipper-requires');
const flipperRequires = require('../plugin-flipper-requires');
const babelOptions = {
ast: true,

View File

@@ -0,0 +1,43 @@
/**
* 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
*/
import {CallExpression, Identifier} from '@babel/types';
import {NodePath} from '@babel/traverse';
import {
tryReplaceFlipperRequire,
tryReplaceGlobalReactUsage,
} from './replace-flipper-requires';
import {resolve} from 'path';
const sourceRootDir = resolve(__dirname, '..', '..');
function isExcludedPath(path: string) {
// We shouldn't apply transformations for the plugins which are part of the repository
// as they are bundled with Flipper app and all use the single "react" dependency.
// But we should apply it for the plugins outside of Flipper folder, so they can be loaded
// in dev mode and use "react" from Flipper bundle.
return path.startsWith(sourceRootDir);
}
module.exports = () => ({
visitor: {
CallExpression(path: NodePath<CallExpression>, state: any) {
if (isExcludedPath(state.file.opts.filename)) {
return;
}
tryReplaceFlipperRequire(path);
},
Identifier(path: NodePath<Identifier>, state: any) {
if (isExcludedPath(state.file.opts.filename)) {
return;
}
tryReplaceGlobalReactUsage(path);
},
},
});

View File

@@ -7,22 +7,20 @@
* @format
*/
import {
CallExpression,
Identifier,
isStringLiteral,
identifier,
} from '@babel/types';
import {CallExpression, Identifier, isStringLiteral} from '@babel/types';
import {NodePath} from '@babel/traverse';
import {resolve, dirname} from 'path';
import {resolve, dirname, relative} from 'path';
import {
tryReplaceFlipperRequire,
tryReplaceGlobalReactUsage,
} from './replace-flipper-requires';
// do not apply this transform for these paths
const EXCLUDE_PATHS = [
'/node_modules/react-devtools-core/',
'relay-devtools/DevtoolsUI',
];
function isExcludedPath(path: string) {
for (const epath of EXCLUDE_PATHS) {
if (path.indexOf(epath) > -1) {
@@ -31,36 +29,20 @@ function isExcludedPath(path: string) {
}
return false;
}
function isReactImportIdentifier(path: NodePath<Identifier>) {
return (
path.parentPath.node.type === 'ImportNamespaceSpecifier' &&
path.parentPath.node.local.name === 'React'
);
}
module.exports = () => ({
visitor: {
CallExpression(path: NodePath<CallExpression>, state: any) {
if (isExcludedPath(state.file.opts.filename)) {
return;
}
const node = path.node;
const args = node.arguments || [];
if (
node.callee.type === 'Identifier' &&
node.callee.name === 'require' &&
args.length === 1 &&
isStringLiteral(args[0])
) {
if (args[0].value === 'flipper') {
path.replaceWith(identifier('global.Flipper'));
} else if (args[0].value === 'react') {
path.replaceWith(identifier('global.React'));
} else if (args[0].value === 'react-dom') {
path.replaceWith(identifier('global.ReactDOM'));
} else if (args[0].value === 'adbkit') {
path.replaceWith(identifier('global.adbkit'));
} else if (
if (!tryReplaceFlipperRequire(path)) {
const node = path.node;
const args = node.arguments || [];
if (
node.callee.type === 'Identifier' &&
node.callee.name === 'require' &&
args.length === 1 &&
isStringLiteral(args[0]) &&
// require a file not a pacakge
args[0].value.indexOf('/') > -1 &&
// in the plugin itself and not inside one of its dependencies
@@ -68,14 +50,14 @@ module.exports = () => ({
// the resolved path for this file is outside the plugins root
!resolve(
state.file.opts.root,
relative(state.file.opts.cwd, dirname(state.file.opts.filename)),
dirname(state.file.opts.filename),
args[0].value,
).startsWith(state.file.opts.root)
) {
throw new Error(
`Plugins cannot require files from outside their folder. Attempted to require ${resolve(
state.file.opts.root,
relative(state.file.opts.cwd, dirname(state.file.opts.filename)),
dirname(state.file.opts.filename),
args[0].value,
)} which isn't inside ${state.file.opts.root}`,
);
@@ -83,14 +65,10 @@ module.exports = () => ({
}
},
Identifier(path: NodePath<Identifier>, state: any) {
if (
path.node.name === 'React' &&
(path.parentPath.node as any).id !== path.node &&
!isReactImportIdentifier(path) &&
!isExcludedPath(state.file.opts.filename)
) {
path.replaceWith(identifier('global.React'));
if (isExcludedPath(state.file.opts.filename)) {
return;
}
tryReplaceGlobalReactUsage(path);
},
},
});

View File

@@ -0,0 +1,60 @@
/**
* 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
*/
import {
CallExpression,
isStringLiteral,
identifier,
Identifier,
} from '@babel/types';
import {NodePath} from '@babel/traverse';
const requireReplacements: any = {
flipper: 'global.Flipper',
react: 'global.React',
'react-dom': 'global.ReactDOM',
adbkit: 'global.adbkit',
};
export function tryReplaceFlipperRequire(path: NodePath<CallExpression>) {
const node = path.node;
const args = node.arguments || [];
if (
node.callee.type === 'Identifier' &&
node.callee.name === 'require' &&
args.length === 1 &&
isStringLiteral(args[0])
) {
const replacement = requireReplacements[args[0].value];
if (replacement) {
path.replaceWith(identifier(replacement));
return true;
}
}
return false;
}
export function tryReplaceGlobalReactUsage(path: NodePath<Identifier>) {
if (
path.node.name === 'React' &&
(path.parentPath.node as any).id !== path.node &&
!isReactImportIdentifier(path)
) {
path.replaceWith(identifier('global.React'));
return true;
}
return false;
}
function isReactImportIdentifier(path: NodePath<Identifier>) {
return (
path.parentPath.node.type === 'ImportNamespaceSpecifier' &&
path.parentPath.node.local.name === 'React'
);
}

View File

@@ -35,5 +35,6 @@ function transform({
}
plugins.push(require('./electron-requires'));
plugins.push(require('./import-react'));
plugins.push(require('./app-flipper-requires'));
return doTransform({filename, options, src, presets, plugins});
}

View File

@@ -32,6 +32,6 @@ export default function transform({
plugins.push(require('./electron-stubs'));
}
plugins.push(require('./electron-requires'));
plugins.push(require('./flipper-requires'));
plugins.push(require('./plugin-flipper-requires'));
return doTransform({filename, options, src, presets, plugins});
}

View File

@@ -11,6 +11,7 @@ import {default as generate} from '@babel/generator';
import {parse} from '@babel/parser';
import {transformFromAstSync} from '@babel/core';
import {default as flipperEnv} from './flipper-env';
import {resolve} from 'path';
export default function transform({
filename,
@@ -25,6 +26,7 @@ export default function transform({
presets?: any[];
plugins?: any[];
}) {
filename = resolve(options.projectRoot, filename);
presets = presets ?? [require('@babel/preset-react')];
plugins = plugins ?? [];
const isTypeScript = filename.endsWith('.tsx') || filename.endsWith('.ts');

View File

@@ -28,6 +28,7 @@ import getAppWatchFolders from './get-app-watch-folders';
import getPlugins from '../static/getPlugins';
import getPluginFolders from '../static/getPluginFolders';
import startWatchPlugins from '../static/startWatchPlugins';
import ensurePluginFoldersWatchable from '../static/ensurePluginFoldersWatchable';
const ansiToHtmlConverter = new AnsiToHtmlConverter();
@@ -268,12 +269,13 @@ function outputScreen(socket?: socketIo.Server) {
}
(async () => {
await generatePluginEntryPoints();
await ensurePluginFoldersWatchable();
const port = await detect(DEFAULT_PORT);
const {app, server} = await startAssetServer(port);
const socket = await addWebsocket(server);
await startMetroServer(app, server);
outputScreen(socket);
await compileMain();
await generatePluginEntryPoints();
shutdownElectron = launchElectron(port);
})();

View File

@@ -16,6 +16,7 @@ import {homedir} from 'os';
import {runBuild, PluginDetails} from 'flipper-pkg-lib';
import getPlugins from './getPlugins';
import startWatchPlugins from './startWatchPlugins';
import ensurePluginFoldersWatchable from './ensurePluginFoldersWatchable';
const HOME_DIR = homedir();
@@ -44,6 +45,7 @@ export default async function (
);
return [];
}
await ensurePluginFoldersWatchable();
options = Object.assign({}, DEFAULT_COMPILE_OPTIONS, options);
const defaultPlugins = (
await fs.readJson(path.join(__dirname, 'defaultPlugins', 'index.json'))

View File

@@ -0,0 +1,38 @@
/**
* 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
*/
import getPluginFolders from './getPluginFolders';
import fs from 'fs-extra';
const watchmanconfigName = '.watchmanconfig';
import path from 'path';
export default async function ensurePluginFoldersWatchable() {
const pluginFolders = await getPluginFolders();
for (const pluginFolder of pluginFolders) {
if (!(await hasParentWithWatchmanConfig(pluginFolder))) {
// If no watchman config found in the plugins folder or any its parent, we need to create it.
// Otherwise we won't be able to listen for plugin changes.
await fs.writeJson(path.join(pluginFolder, watchmanconfigName), {});
}
}
}
async function hasParentWithWatchmanConfig(dir: string): Promise<boolean> {
if (await fs.pathExists(path.join(dir, watchmanconfigName))) {
return true;
} else {
const parent = path.dirname(dir);
if (parent && parent != '' && parent !== dir) {
return await hasParentWithWatchmanConfig(parent);
}
}
return false;
}