Merge pull request #118 from spotify/patriko/serve-plugin

packages/cli: add serve and watch-deps
This commit is contained in:
Patrik Oldsberg
2020-02-28 11:23:19 +01:00
committed by GitHub
38 changed files with 1647 additions and 498 deletions
+13 -1
View File
@@ -14,9 +14,14 @@
"devDependencies": {
"@spotify/web-scripts": "^6.0.0",
"@types/fs-extra": "^8.1.0",
"@types/html-webpack-plugin": "^3.2.2",
"@types/inquirer": "^6.5.0",
"@types/node": "^13.7.2",
"@types/react-dev-utils": "^9.0.4",
"@types/recursive-readdir": "^2.2.0",
"@types/webpack": "^4.41.7",
"@types/webpack-dev-server": "^3.10.0",
"del": "^5.1.0",
"nodemon": "^2.0.2",
"ts-node": "^8.6.2"
},
@@ -24,13 +29,20 @@
"backstage-cli": "bin/backstage-cli"
},
"dependencies": {
"chokidar": "^3.3.1",
"commander": "^4.1.1",
"dashify": "^2.0.0",
"fork-ts-checker-webpack-plugin": "^4.0.5",
"fs-extra": "^8.1.0",
"handlebars": "^4.7.3",
"html-webpack-plugin": "^3.2.0",
"inquirer": "^7.0.4",
"react-dev-utils": "^10.2.0",
"recursive-readdir": "^2.2.2",
"replace-in-file": "^5.0.2"
"replace-in-file": "^5.0.2",
"ts-loader": "^6.2.1",
"webpack": "^4.41.6",
"webpack-dev-server": "^3.10.3"
},
"files": [
"templates",
@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import del from 'del';
import {
createFileFromTemplate,
createFromTemplateDir,
@@ -16,7 +17,7 @@ describe('createPlugin', () => {
expect(fs.existsSync(pluginFolder)).toBe(true);
expect(pluginFolder).toMatch(/packages\/plugins\/foo/);
} finally {
fs.rmdirSync(tempDir, { recursive: true });
del.sync(tempDir, { force: true });
}
});
@@ -29,7 +30,7 @@ describe('createPlugin', () => {
/A plugin with the same name already exists/,
);
} finally {
fs.rmdirSync(tempDir, { recursive: true });
del.sync(tempDir, { force: true });
}
});
});
@@ -49,7 +50,7 @@ describe('createPlugin', () => {
expect(fs.existsSync(targetPath)).toBe(true);
expect(fs.readFileSync(targetPath).toString()).toBe(targetData);
} finally {
fs.rmdirSync(tempDir, { recursive: true });
del.sync(tempDir, { force: true });
}
});
});
@@ -77,10 +78,9 @@ describe('createPlugin', () => {
expect(fs.existsSync(subDir)).toBe(true);
expect(fs.existsSync(testFile)).toBe(true);
} finally {
fs.rmdirSync(templateRootDir, { recursive: true });
fs.rmdirSync(destinationRootDir, { recursive: true });
await del(templateRootDir, { force: true });
await del(destinationRootDir, { force: true });
}
});
xit('should handle errors on reading template directory', async () => {});
});
});
@@ -0,0 +1,111 @@
import webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
import { Paths } from './paths';
// import checkRequiredFiles from 'react-dev-utils/checkRequiredFiles';
// import ModuleNotFoundPlugin from 'react-dev-utils/ModuleNotFoundPlugin';
// import errorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware';
// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin';
export function createConfig(paths: Paths): webpack.Configuration {
return {
mode: 'development',
profile: false,
bail: false,
devtool: 'cheap-module-eval-source-map',
context: paths.appPath,
entry: [
`${require.resolve('webpack-dev-server/client')}?/`,
require.resolve('webpack/hot/dev-server'),
paths.appDevEntry,
],
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
plugins: [
new ModuleScopePlugin(
[paths.appSrc, paths.appDev],
[paths.appPackageJson],
),
],
},
module: {
rules: [
{
test: /\.(tsx?|jsx?|mjs)$/,
enforce: 'pre',
include: [paths.appSrc, paths.appDev],
use: {
loader: 'eslint-loader',
options: {
emitWarning: true,
},
},
},
{
test: /\.(tsx?|jsx?|mjs)$/,
include: [paths.appSrc, paths.appDev],
exclude: /node_modules/,
loader: 'ts-loader',
options: {
// disable type checker - handled by ForkTsCheckerWebpackPlugin
transpileOnly: true,
},
},
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
loader: 'url-loader',
include: paths.appAssets,
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
{
test: /\.ya?ml$/,
use: 'yml-loader',
},
{
include: /\.(md)$/,
use: 'raw-loader',
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
output: {
publicPath: '/',
filename: 'bundle.js',
},
plugins: [
new HtmlWebpackPlugin({
template: paths.appHtml,
}),
new ForkTsCheckerWebpackPlugin({
tsconfig: paths.appTsConfig,
eslint: true,
eslintOptions: {
parserOptions: {
project: paths.appTsConfig,
tsconfigRootDir: paths.appPath,
},
},
reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
}),
new webpack.HotModuleReplacementPlugin(),
],
node: {
module: 'empty',
dgram: 'empty',
dns: 'mock',
fs: 'empty',
http2: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
};
}
@@ -0,0 +1,11 @@
import chalk from 'chalk';
import { startDevServer } from './server';
export default async () => {
try {
await startDevServer();
} catch (error) {
process.stderr.write(`${chalk.red(error.message)}\n`);
process.exit(1);
}
};
@@ -0,0 +1,37 @@
import { resolve as resolvePath } from 'path';
import { existsSync, realpathSync } from 'fs';
export function getPaths() {
const appDir = realpathSync(process.cwd());
const resolveApp = (path: string) => resolvePath(appDir, path);
const resolveOwn = (path: string) => resolvePath(__dirname, '..', path);
const resolveAppModule = (path: string) => {
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
const filePath = resolveApp(`${path}.${ext}`);
if (existsSync(filePath)) {
return filePath;
}
}
return resolveApp(`${path}.js`);
};
let appHtml = resolveApp('dev/index.html');
if (!existsSync(appHtml)) {
appHtml = resolveOwn('../../templates/serve_index.html');
}
return {
appHtml,
appPath: resolveApp('.'),
appAssets: resolveApp('assets'),
appSrc: resolveApp('src'),
appDev: resolveApp('dev'),
appDevEntry: resolveAppModule('dev/index'),
appTsConfig: resolveApp('tsconfig.json'),
appNodeModules: resolveApp('node_modules'),
appPackageJson: resolveApp('package.json'),
};
}
export type Paths = ReturnType<typeof getPaths>;
@@ -0,0 +1,43 @@
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import openBrowser from 'react-dev-utils/openBrowser';
import { choosePort, prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
import { getPaths } from './paths';
import { createConfig } from './config';
export async function startDevServer() {
const host = process.env.HOST ?? '0.0.0.0';
const defaultPort = parseInt(process.env.PORT ?? '', 10) || 3000;
const port = await choosePort(host, defaultPort);
if (!port) {
return;
}
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const urls = prepareUrls(protocol, host, port);
const paths = getPaths();
const config = createConfig(paths);
const compiler = webpack(config);
const server = new WebpackDevServer(compiler, {
hot: true,
publicPath: '/',
quiet: true,
https: protocol === 'https',
host,
port,
});
await new Promise((resolve, reject) => {
server.listen(port, host, (err?: Error) => {
if (err) {
reject(err);
return;
}
openBrowser(urls.localUrlForBrowser);
resolve();
});
});
}
@@ -0,0 +1,20 @@
import { spawn } from 'child_process';
import { createLogger } from './logger';
export function startChild(args: string[]) {
const [command, ...commandArgs] = args;
const child = spawn(command, commandArgs, {
env: { FORCE_COLOR: 'true', ...process.env },
stdio: ['inherit', 'pipe', 'pipe'],
});
// We need to avoid clearing the terminal, or the build feedback of dependencies will be lost
const log = createLogger();
child.stdout!.on('data', (data: Buffer) => {
log.out(data.toString('utf8'));
});
child.stderr!.on('data', data => {
log.err(data.toString('utf8'));
});
}
@@ -0,0 +1,50 @@
import { spawn } from 'child_process';
import { Logger } from './logger';
import chalk from 'chalk';
import { Package } from './packages';
export function startCompiler(pkg: Package, log: Logger) {
// First we figure out which yarn script is a available, falling back to "build --watch"
const scriptName = ['build:watch', 'watch'].find(
script => script in pkg.scripts,
);
const args = scriptName ? [scriptName] : ['build', '--watch'];
// Start the watch script inside the dependency
const watch = spawn('yarn', ['run', ...args], {
cwd: pkg.location,
env: { FORCE_COLOR: 'true', ...process.env },
stdio: 'pipe',
});
watch.stdin.end();
watch.stdout!.on('data', (data: Buffer) => {
log.out(data.toString('utf8'));
});
watch.stderr!.on('data', data => {
log.err(data.toString('utf8'));
});
const promise = new Promise<void>((resolve, reject) => {
watch.on('error', error => {
reject(error);
});
watch.on('close', (code: number) => {
if (code !== 0) {
const msg = `Compiler exited with code ${code}`;
log.err(chalk.red(msg));
reject(new Error(msg));
} else {
resolve();
}
});
});
return {
promise,
close() {
watch.kill('SIGINT');
},
};
}
@@ -0,0 +1,53 @@
import { resolve as resolvePath } from 'path';
import { readFileSync } from 'fs';
import chalk from 'chalk';
import { createLoggerFactory } from './logger';
import { findAllDeps } from './packages';
import { startWatchers } from './watcher';
import { startCompiler } from './compiler';
import { startChild } from './child';
const PACKAGE_BLACKLIST = [
// We never want to watch for changes in the cli, but all packages will depend on it.
'@spotify-backstage/cli',
];
const WATCH_LOCATIONS = ['package.json', 'src', 'assets'];
/*
* The watch-deps command is meant to improve iteration speed while working in a large monorepo
* with packages that are built independently, meaning packages depends on each other's build output.
*
* The command traverses all dependencies of the current package within the monorepo, and starts
* watching for updates in all those packages. If a change is detected, we stop listening for changes,
* and instead start up watch mode for that package. Starting watch mode means running the first
* available yarn script out of "build:watch", "watch", or "build" --watch.
*/
export default async (_command: any, args: string[]) => {
const localPackagePath = resolvePath('package.json');
const packageJson = JSON.parse(readFileSync(localPackagePath, 'utf8'));
// Find all direct and transitive local dependencies of the current package.
const allDeps = await findAllDeps(packageJson.name, PACKAGE_BLACKLIST);
// Rotate through different prefix colors to make it easier to differenciate between different deps
const logFactory = createLoggerFactory([
chalk.yellow,
chalk.blue,
chalk.magenta,
chalk.green,
chalk.cyan,
]);
// We lazily watch all our deps, as in we don't start the actual watch compiler until a change is detected
await startWatchers(allDeps, WATCH_LOCATIONS, pkg => {
startCompiler(pkg, logFactory(pkg.name)).promise.catch(error => {
process.stderr.write(`${error}\n`);
});
});
if (args.length) {
startChild(args);
}
};
@@ -0,0 +1,38 @@
export type Logger = {
out(msg: string): void;
err(msg: string): void;
};
export type ColorFunc = (msg: string) => string;
// Logger utility that prefixes logs and removes terminal clear commands
export function createLogger(prefix: string = ''): Logger {
const write = (stream: NodeJS.WriteStream, msg: string) => {
const noClearMsg = msg.startsWith('\x1b\x63') ? msg.slice(2) : msg;
const prefixedMsg = noClearMsg.trimRight().replace(/^/gm, prefix);
stream.write(`${prefixedMsg}\n`, 'utf8');
};
return {
out(msg: string) {
write(process.stdout, msg);
},
err(msg: string) {
write(process.stderr, msg);
},
};
}
// A factory for creating loggers that rotate between different coloring functions
export function createLoggerFactory(colorFuncs: ColorFunc[]) {
let colorIndex = 0;
return (name: string) => {
const colorFunc = colorFuncs[colorIndex];
colorIndex = (colorIndex + 1) % colorFuncs.length;
const prefix = `${colorFunc(name)}: `;
return createLogger(prefix);
};
}
@@ -0,0 +1,46 @@
import { resolve as resolvePath } from 'path';
const LernaProject = require('@lerna/project');
const PackageGraph = require('@lerna/package-graph');
export type Package = {
name: string;
location: string;
scripts: { [name in string]: string };
};
// Uses lerna to find all local deps of the root package, excluding itself or any package in the blacklist
export async function findAllDeps(
rootPackageName: string,
blacklist: string[],
): Promise<Package[]> {
const project = new LernaProject(resolvePath('.'));
const packages = await project.getPackages();
const graph = new PackageGraph(packages);
const deps = new Map<string, any>();
const searchNames = [rootPackageName];
while (searchNames.length) {
const name = searchNames.pop()!;
if (deps.has(name)) {
continue;
}
const node = graph.get(name);
if (!node) {
throw new Error(`Package '${name}' not found`);
}
searchNames.push(...node.localDependencies.keys());
deps.set(name, node.pkg);
}
deps.delete(rootPackageName);
for (const name of blacklist) {
deps.delete(name);
}
return [...deps.values()];
}
@@ -0,0 +1,45 @@
import { resolve as resolvePath } from 'path';
import chokidar from 'chokidar';
import { Package } from './packages';
/*
* Watch for changes inside a collection of packages. When a change is detected, stop
* watching and call the callback with the package the change occured in.
*
* The returned promise is resolved once all watchers are ready.
*/
export async function startWatchers(
packages: Package[],
paths: string[],
callback: (pkg: Package) => void,
): Promise<void> {
const readyPromises = [];
for (const pkg of packages) {
let signalled = false;
const watchLocations = paths.map(path => resolvePath(pkg.location, path));
const watcher = chokidar
.watch(watchLocations, {
cwd: pkg.location,
ignoreInitial: true,
disableGlobbing: true,
})
.on('all', () => {
if (!signalled) {
signalled = true;
callback(pkg);
}
watcher.close();
});
readyPromises.push(
new Promise((resolve, reject) => {
watcher.on('ready', resolve);
watcher.on('error', reject);
}),
);
}
await Promise.all(readyPromises);
}
+17
View File
@@ -1,5 +1,11 @@
import program from 'commander';
import createPluginCommand from './commands/createPlugin';
import watch from './commands/watch-deps';
import serve from './commands/serve';
process.on('unhandledRejection', err => {
throw err;
});
const main = (argv: string[]) => {
program
@@ -7,7 +13,18 @@ const main = (argv: string[]) => {
.description('Creates a new plugin in the current repository')
.action(createPluginCommand);
program
.command('serve')
.description('Serves the dev/ folder of a package')
.action(serve);
program
.command('watch-deps')
.description('Watch all dependencies while running another command')
.action(watch);
program.on('command:*', () => {
// eslint-disable-next-line no-console
console.error(
'Invalid command: %s\nSee --help for a list of available commands.',
program.args.join(' '),
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Backstage is an open platform for building developer portals"
/>
<title>Backstage</title>
</head>
<body style="margin: 0">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
@@ -0,0 +1,3 @@
# compile-test-app
Used to try out different setups for building packages separately but supporting global watch mode.
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { ExampleComponent } from '../src';
ReactDOM.render(
<main>
<h1>This is the dev app</h1>
<ExampleComponent />
</main>,
document.getElementById('root'),
);
@@ -0,0 +1,4 @@
module.exports = {
...require('@spotify/web-scripts/config/jest.config.js'),
setupFilesAfterEnv: ['../jest.setup.ts'],
};
@@ -0,0 +1 @@
import '@testing-library/jest-dom/extend-expect';
@@ -0,0 +1,18 @@
{
"name": "compile-test-app",
"version": "0.0.0",
"main": "dist/cjs/index.js",
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "tsc --outDir dist/cjs --noEmit false --module CommonJS",
"start": "backstage-cli watch-deps -- backstage-cli serve",
"lint": "web-scripts lint",
"test": "web-scripts test"
},
"devDependencies": {
"@spotify/web-scripts": "^6.0.0",
"@spotify-backstage/cli": "^1.2.0",
"compile-test-lib": "0.0.0"
}
}
@@ -0,0 +1,13 @@
import React, { FC } from 'react';
import { getMessage } from 'compile-test-lib';
const ExampleComponent: FC<{}> = () => {
return (
<div>
<h2>This is the ExampleComponent</h2>
<p>{getMessage()}</p>
</div>
);
};
export default ExampleComponent;
@@ -0,0 +1,5 @@
describe('dummy', () => {
it('dummy', () => {
expect(1).toBe(1);
});
});
@@ -0,0 +1 @@
export { default as ExampleComponent } from './ExampleComponent';
@@ -0,0 +1,4 @@
{
"extends": "@spotify/web-scripts/config/tsconfig.json",
"include": ["dev", "src"]
}
@@ -0,0 +1,3 @@
# compile-test-lib
Used to try out different setups for building packages separately but supporting global watch mode.
@@ -0,0 +1,4 @@
module.exports = {
...require('@spotify/web-scripts/config/jest.config.js'),
setupFilesAfterEnv: ['../jest.setup.ts'],
};
@@ -0,0 +1 @@
import '@testing-library/jest-dom/extend-expect';
@@ -0,0 +1,16 @@
{
"name": "compile-test-lib-2",
"version": "0.0.0",
"main": "dist/cjs",
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "tsc --outDir dist/cjs --noEmit false --module CommonJS",
"build:watch": "yarn run build --watch",
"lint": "web-scripts lint",
"test": "web-scripts test"
},
"devDependencies": {
"@spotify/web-scripts": "^6.0.0"
}
}
@@ -0,0 +1,5 @@
describe('dummy', () => {
it('dummy', () => {
expect(1).toBe(1);
});
});
@@ -0,0 +1,3 @@
export function getPartialMessage(): string {
return '<lib2/>';
}
@@ -0,0 +1,4 @@
{
"extends": "@spotify/web-scripts/config/tsconfig.json",
"include": ["src"]
}
@@ -0,0 +1,3 @@
# compile-test-lib
Used to try out different setups for building packages separately but supporting global watch mode.
@@ -0,0 +1,4 @@
module.exports = {
...require('@spotify/web-scripts/config/jest.config.js'),
setupFilesAfterEnv: ['../jest.setup.ts'],
};
@@ -0,0 +1 @@
import '@testing-library/jest-dom/extend-expect';
@@ -0,0 +1,19 @@
{
"name": "compile-test-lib",
"version": "0.0.0",
"main": "dist/cjs/index.js",
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "tsc --outDir dist/cjs --noEmit false --module CommonJS",
"build:watch": "yarn run build --watch",
"lint": "web-scripts lint",
"test": "web-scripts test"
},
"dependencies": {
"compile-test-lib-2": "0.0.0"
},
"devDependencies": {
"@spotify/web-scripts": "^6.0.0"
}
}
@@ -0,0 +1,5 @@
describe('dummy', () => {
it('dummy', () => {
expect(1).toBe(1);
});
});
@@ -0,0 +1,5 @@
import { getPartialMessage } from 'compile-test-lib-2';
export function getMessage(): string {
return `<lib> ${getPartialMessage()} </lib>`;
}
@@ -0,0 +1,4 @@
{
"extends": "@spotify/web-scripts/config/tsconfig.json",
"include": ["src"]
}
+993 -491
View File
File diff suppressed because it is too large Load Diff