Summary: New UX/UI for plugin auto-updates based on Sandy: - disabled plugins auto-updated silently without any notifications as there is no active state for them so there is nothing to loose. - enabled plugins can have some state and user can actually work with them, so we cannot reload them automatically. Instead, we show notification in the top of the plugin container asking user to reload the plugin when she is ready. - if the auto-updated plugin failed to reload - show error notification. - for non-sandy we continue using notifications as before. Reviewed By: mweststrate Differential Revision: D25530384 fbshipit-source-id: de3d0565ef0b930c9343b9e0ed07a4acb51885be
397 lines
12 KiB
TypeScript
397 lines
12 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
|
|
*/
|
|
|
|
const dotenv = require('dotenv').config();
|
|
const electronBinary: string = require('electron') as any;
|
|
import codeFrame from '@babel/code-frame';
|
|
import socketIo from 'socket.io';
|
|
import express, {Express} from 'express';
|
|
import detect from 'detect-port';
|
|
import child from 'child_process';
|
|
import AnsiToHtmlConverter from 'ansi-to-html';
|
|
import chalk from 'chalk';
|
|
import http from 'http';
|
|
import path from 'path';
|
|
import fs from 'fs-extra';
|
|
import {hostname} from 'os';
|
|
import {compileMain, generatePluginEntryPoints} from './build-utils';
|
|
import Watchman from './watchman';
|
|
import Metro from 'metro';
|
|
import MetroResolver from 'metro-resolver';
|
|
import {staticDir, appDir, babelTransformationsDir} from './paths';
|
|
import isFB from './isFB';
|
|
import getAppWatchFolders from './get-app-watch-folders';
|
|
import {getPluginSourceFolders} from 'flipper-plugin-lib';
|
|
import ensurePluginFoldersWatchable from './ensurePluginFoldersWatchable';
|
|
import startWatchPlugins from './startWatchPlugins';
|
|
import yargs from 'yargs';
|
|
|
|
const argv = yargs
|
|
.usage('yarn start [args]')
|
|
.options({
|
|
'embedded-plugins': {
|
|
describe:
|
|
'Enables embedding of plugins into Flipper bundle. If it disabled then only installed plugins are loaded. The flag is enabled by default. Env var FLIPPER_NO_EMBEDDED_PLUGINS is equivalent to the command-line option "--no-embedded-plugins".',
|
|
type: 'boolean',
|
|
},
|
|
'fast-refresh': {
|
|
describe:
|
|
'Enable Fast Refresh - quick reload of UI component changes without restarting Flipper. The flag is disabled by default. Env var FLIPPER_FAST_REFRESH is equivalent to the command-line option "--fast-refresh".',
|
|
type: 'boolean',
|
|
},
|
|
'plugin-auto-update': {
|
|
describe:
|
|
'[FB-internal only] Enable plugin auto-updates. The flag is disabled by default in dev mode. Env var FLIPPER_NO_PLUGIN_AUTO_UPDATE is equivalent to the command-line option "--no-plugin-auto-update"',
|
|
type: 'boolean',
|
|
},
|
|
'plugin-auto-update-interval': {
|
|
describe:
|
|
'[FB-internal only] Set custom interval in milliseconds for plugin auto-update checks. Env var FLIPPER_PLUGIN_AUTO_UPDATE_POLLING_INTERVAL is equivalent to this command-line option.',
|
|
type: 'number',
|
|
},
|
|
'enabled-plugins': {
|
|
describe:
|
|
'Load only specified plugins and skip loading rest. This is useful when you are developing only one or few plugins. Plugins to load can be specified as a comma-separated list with either plugin id or name used as identifier, e.g. "--enabled-plugins network,inspector". The flag is not provided by default which means that all plugins loaded.',
|
|
type: 'array',
|
|
},
|
|
'open-dev-tools': {
|
|
describe:
|
|
'Open Dev Tools window on startup. The flag is disabled by default. Env var FLIPPER_OPEN_DEV_TOOLS is equivalent to the command-line option "--open-dev-tools".',
|
|
type: 'boolean',
|
|
},
|
|
'dev-server-port': {
|
|
describe:
|
|
'Dev server port. 3000 by default. Env var "PORT=3001" is equivalent to the command-line option "--dev-server-port 3001".',
|
|
default: 3000,
|
|
type: 'number',
|
|
},
|
|
'enable-all-gks': {
|
|
describe:
|
|
'[FB-internal only] Will yield `true` on any GK. Disabled by default. Setting env var FLIPPER_ENABLE_ALL_GKS is equivalent',
|
|
type: 'boolean',
|
|
},
|
|
channel: {
|
|
describe:
|
|
'[FB-internal only] Release channel. "stable" by default. Setting env var "FLIPPER_RELEASE_CHANNEL" is equivalent.',
|
|
choices: ['stable', 'insiders'],
|
|
},
|
|
})
|
|
.version('DEV')
|
|
.help()
|
|
.parse(process.argv.slice(1));
|
|
|
|
const ansiToHtmlConverter = new AnsiToHtmlConverter();
|
|
|
|
const DEFAULT_PORT = (process.env.PORT || 3000) as number;
|
|
|
|
let shutdownElectron: (() => void) | undefined = undefined;
|
|
|
|
if (isFB) {
|
|
process.env.FLIPPER_FB = 'true';
|
|
}
|
|
|
|
if (argv['embedded-plugins'] === true) {
|
|
delete process.env.FLIPPER_NO_EMBEDDED_PLUGINS;
|
|
} else if (argv['embedded-plugins'] === false) {
|
|
process.env.FLIPPER_NO_EMBEDDED_PLUGINS = 'true';
|
|
}
|
|
|
|
if (argv['fast-refresh'] === true) {
|
|
process.env.FLIPPER_FAST_REFRESH = 'true';
|
|
} else if (argv['fast-refresh'] === false) {
|
|
delete process.env.FLIPPER_FAST_REFRESH;
|
|
}
|
|
|
|
// By default plugin auto-update is disabled in dev mode,
|
|
// but it is possible to enable it using this command line
|
|
// argument or env var.
|
|
if (
|
|
argv['plugin-auto-update'] === true ||
|
|
process.env.FLIPPER_PLUGIN_AUTO_UPDATE
|
|
) {
|
|
delete process.env.FLIPPER_DISABLE_PLUGIN_AUTO_UPDATE;
|
|
} else {
|
|
process.env.FLIPPER_DISABLE_PLUGIN_AUTO_UPDATE = 'true';
|
|
}
|
|
|
|
if (argv['plugin-auto-update-interval']) {
|
|
process.env.FLIPPER_PLUGIN_AUTO_UPDATE_POLLING_INTERVAL = `${argv['plugin-auto-update-interval']}`;
|
|
}
|
|
|
|
// Force participating in all GKs. Mostly intersting for Flipper team members.
|
|
if (argv['enable-all-gks'] === true) {
|
|
process.env.FLIPPER_ENABLE_ALL_GKS = 'true';
|
|
}
|
|
|
|
if (argv['enabled-plugins'] !== undefined) {
|
|
process.env.FLIPPER_ENABLED_PLUGINS = argv['enabled-plugins'].join(',');
|
|
}
|
|
|
|
if (argv.channel !== undefined) {
|
|
process.env.FLIPPER_RELEASE_CHANNEL = argv.channel;
|
|
}
|
|
|
|
function looksLikeDevServer(): boolean {
|
|
const hn = hostname();
|
|
if (/^devvm.*\.facebook\.com$/.test(hn)) {
|
|
return true;
|
|
}
|
|
if (hn.endsWith('.od.fbinfra.net')) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function launchElectron(port: number) {
|
|
const entry = process.env.FLIPPER_FAST_REFRESH ? 'init-fast-refresh' : 'init';
|
|
const devServerURL = `http://localhost:${port}`;
|
|
const bundleURL = `http://localhost:${port}/src/${entry}.bundle?platform=web&dev=true&minify=false`;
|
|
const electronURL = `http://localhost:${port}/index.dev.html`;
|
|
const args = [
|
|
path.join(staticDir, 'index.js'),
|
|
'--remote-debugging-port=9222',
|
|
...process.argv,
|
|
];
|
|
const proc = child.spawn(electronBinary, args, {
|
|
cwd: staticDir,
|
|
env: {
|
|
...process.env,
|
|
SONAR_ROOT: process.cwd(),
|
|
BUNDLE_URL: bundleURL,
|
|
ELECTRON_URL: electronURL,
|
|
DEV_SERVER_URL: devServerURL,
|
|
},
|
|
stdio: 'inherit',
|
|
});
|
|
|
|
const electronCloseListener = () => {
|
|
process.exit();
|
|
};
|
|
|
|
const processExitListener = () => {
|
|
proc.kill();
|
|
};
|
|
|
|
proc.on('close', electronCloseListener);
|
|
process.on('exit', processExitListener);
|
|
|
|
return () => {
|
|
proc.off('close', electronCloseListener);
|
|
process.off('exit', processExitListener);
|
|
proc.kill();
|
|
};
|
|
}
|
|
|
|
async function startMetroServer(app: Express, server: http.Server) {
|
|
const watchFolders = (await getAppWatchFolders()).concat(
|
|
await getPluginSourceFolders(),
|
|
);
|
|
const baseConfig = await Metro.loadConfig();
|
|
const config = Object.assign({}, baseConfig, {
|
|
projectRoot: appDir,
|
|
watchFolders,
|
|
transformer: {
|
|
...baseConfig.transformer,
|
|
babelTransformerPath: path.join(babelTransformationsDir, 'transform-app'),
|
|
},
|
|
resolver: {
|
|
...baseConfig.resolver,
|
|
resolverMainFields: ['flipperBundlerEntry', 'module', 'main'],
|
|
blacklistRE: /\.native\.js$/,
|
|
resolveRequest: (context: any, moduleName: string, platform: string) => {
|
|
if (moduleName.startsWith('./localhost:3000')) {
|
|
moduleName = moduleName.replace('./localhost:3000', '.');
|
|
}
|
|
return MetroResolver.resolve(
|
|
{...context, resolveRequest: null},
|
|
moduleName,
|
|
platform,
|
|
);
|
|
},
|
|
sourceExts: ['js', 'jsx', 'ts', 'tsx', 'json', 'mjs', 'cjs'],
|
|
},
|
|
watch: true,
|
|
});
|
|
const connectMiddleware = await Metro.createConnectMiddleware(config);
|
|
app.use(connectMiddleware.middleware);
|
|
connectMiddleware.attachHmrServer(server);
|
|
}
|
|
|
|
function startAssetServer(
|
|
port: number,
|
|
): Promise<{app: Express; server: http.Server}> {
|
|
const app = express();
|
|
|
|
app.use((req, _res, next) => {
|
|
if (knownErrors[req.url] != null) {
|
|
delete knownErrors[req.url];
|
|
outputScreen();
|
|
}
|
|
next();
|
|
});
|
|
|
|
app.use((_req, res, next) => {
|
|
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
|
|
res.header('Expires', '-1');
|
|
res.header('Pragma', 'no-cache');
|
|
next();
|
|
});
|
|
|
|
app.post('/_restartElectron', (_req, res) => {
|
|
if (shutdownElectron) {
|
|
shutdownElectron();
|
|
}
|
|
shutdownElectron = launchElectron(port);
|
|
res.end();
|
|
});
|
|
|
|
app.get('/', (_req, res) => {
|
|
fs.readFile(path.join(staticDir, 'index.dev.html'), (_err, content) => {
|
|
res.end(content);
|
|
});
|
|
});
|
|
|
|
app.use(express.static(staticDir));
|
|
|
|
app.use(function (err: any, req: any, res: any, _next: any) {
|
|
knownErrors[req.url] = err;
|
|
outputScreen();
|
|
res.status(500).send('Something broke, check the console!');
|
|
});
|
|
|
|
const server = http.createServer(app);
|
|
|
|
return new Promise((resolve) => {
|
|
server.listen(port, 'localhost', () => resolve({app, server}));
|
|
});
|
|
}
|
|
|
|
async function addWebsocket(server: http.Server) {
|
|
const io = socketIo(server);
|
|
|
|
// notify connected clients that there's errors in the console
|
|
io.on('connection', (client) => {
|
|
if (hasErrors()) {
|
|
client.emit('hasErrors', ansiToHtmlConverter.toHtml(buildErrorScreen()));
|
|
}
|
|
});
|
|
|
|
// Refresh the app on changes.
|
|
// When Fast Refresh enabled, reloads are performed by HMRClient, so don't need to watch manually here.
|
|
if (!process.env.FLIPPER_FAST_REFRESH) {
|
|
await startWatchChanges(io);
|
|
}
|
|
|
|
return io;
|
|
}
|
|
|
|
async function startWatchChanges(io: socketIo.Server) {
|
|
try {
|
|
const watchman = new Watchman(path.resolve(__dirname, '..'));
|
|
await watchman.initialize();
|
|
await Promise.all(
|
|
['app', 'pkg', 'doctor', 'plugin-lib', 'flipper-plugin'].map((dir) =>
|
|
watchman.startWatchFiles(
|
|
dir,
|
|
() => {
|
|
io.emit('refresh');
|
|
},
|
|
{
|
|
excludes: ['**/__tests__/**/*', '**/node_modules/**/*', '**/.*'],
|
|
},
|
|
),
|
|
),
|
|
);
|
|
await startWatchPlugins(() => {
|
|
io.emit('refresh');
|
|
});
|
|
} catch (err) {
|
|
console.error(
|
|
'Failed to start watching for changes using Watchman, continue without hot reloading',
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
const knownErrors: {[key: string]: any} = {};
|
|
|
|
function hasErrors() {
|
|
return Object.keys(knownErrors).length > 0;
|
|
}
|
|
|
|
function buildErrorScreen() {
|
|
const lines = [
|
|
chalk.red(`✖ Found ${Object.keys(knownErrors).length} errors`),
|
|
'',
|
|
];
|
|
|
|
for (const url in knownErrors) {
|
|
const err = knownErrors[url];
|
|
|
|
if (err.filename != null && err.lineNumber != null && err.column != null) {
|
|
lines.push(chalk.inverse(err.filename));
|
|
lines.push();
|
|
lines.push(err.message);
|
|
lines.push(
|
|
codeFrame(
|
|
fs.readFileSync(err.filename, 'utf8'),
|
|
err.lineNumber,
|
|
err.column,
|
|
),
|
|
);
|
|
} else {
|
|
lines.push(err.stack);
|
|
}
|
|
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function outputScreen(socket?: socketIo.Server) {
|
|
// output screen
|
|
if (hasErrors()) {
|
|
const errorScreen = buildErrorScreen();
|
|
console.error(errorScreen);
|
|
|
|
// notify live clients of errors
|
|
socket?.emit('hasErrors', ansiToHtmlConverter.toHtml(errorScreen));
|
|
} else {
|
|
// eslint-disable-next-line no-console
|
|
console.log(chalk.green('✔ No known errors'));
|
|
}
|
|
}
|
|
|
|
function checkDevServer() {
|
|
if (looksLikeDevServer()) {
|
|
console.log(
|
|
chalk.red(
|
|
`✖ It looks like you're trying to start Flipper on your OnDemand or DevServer, which is not supported. Please run this in a local checkout on your laptop or desktop instead.`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
checkDevServer();
|
|
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();
|
|
if (dotenv && dotenv.parsed) {
|
|
console.log('✅ Loaded env vars from .env file: ', dotenv.parsed);
|
|
}
|
|
shutdownElectron = launchElectron(port);
|
|
})();
|