Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Nikita Nek Dudnik
2020-05-18 14:41:45 +02:00
111 changed files with 2445 additions and 2716 deletions
+45 -22
View File
@@ -14,45 +14,68 @@
* limitations under the License.
*/
const fs = require('fs');
const fs = require('fs-extra');
const path = require('path');
// If the package has it's own jest config, we use that instead. It will have to
// manually extend @spotify/web-scripts/config/jest.config.js that is desired
if (fs.existsSync('jest.config.js')) {
module.exports = require(path.resolve('jest.config.js'));
} else if (fs.existsSync('jest.config.ts')) {
module.exports = require(path.resolve('jest.config.ts'));
} else {
const extraOptions = {
modulePaths: ['<rootDir>'],
moduleNameMapper: {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
'.+\\.(png|jpg|ttf|woff|woff2)$': 'identity-obj-proxy',
},
async function getConfig() {
// If the package has it's own jest config, we use that instead.
if (await fs.pathExists('jest.config.js')) {
return require(path.resolve('jest.config.js'));
} else if (await fs.pathExists('jest.config.ts')) {
return require(path.resolve('jest.config.ts'));
}
const moduleNameMapper = {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
};
// Only point to src/ if we're not in CI, there we just build packages first anyway
if (!process.env.CI) {
const LernaProject = require('@lerna/project');
const project = new LernaProject(path.resolve('.'));
const packages = await project.getPackages();
// To avoid having to build all deps inside the monorepo before running tests,
// we point directory to src/ where applicable.
// For example, @backstage/core = <repo-root>/packages/core/src/index.ts is added to moduleNameMapper
for (const pkg of packages) {
const mainSrc = pkg.get('main:src');
if (mainSrc) {
moduleNameMapper[pkg.name] = path.resolve(pkg.location, mainSrc);
}
}
}
const options = {
rootDir: path.resolve('src'),
coverageDirectory: path.resolve('coverage'),
collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'],
moduleNameMapper,
// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed
// TODO: jest is working on module support, it's possible that we can remove this in the future
transform: {
'\\.esm\\.js$': require.resolve('jest-esm-transformer'),
'\\.(js|jsx|ts|tsx)': require.resolve('ts-jest'),
},
// Default behaviour is to not apply transforms for node_modules, but we still want to tranform .esm.js files
// Default behaviour is to not apply transforms for node_modules, but we still want
// to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages.
transformIgnorePatterns: ['/node_modules/(?!.*\\.esm\\.js$)'],
};
// Use src/setupTests.ts as the default location for configuring test env
if (fs.existsSync('src/setupTests.ts')) {
extraOptions.setupFilesAfterEnv = ['<rootDir>/setupTests.ts'];
options.setupFilesAfterEnv = ['<rootDir>/setupTests.ts'];
}
module.exports = {
// We base the jest config on web-scripts, it's pretty flat so we skip any complex merging of config objects
// Config can be found here: https://github.com/spotify/web-scripts/blob/master/packages/web-scripts/config/jest.config.js
...require('@spotify/web-scripts/config/jest.config.js'),
...extraOptions,
return {
...options,
// If the package has a jest object in package.json we merge that config in. This is the recommended
// location for configuring tests.
...require(path.resolve('package.json')).jest,
};
}
module.exports = getConfig();
+2
View File
@@ -4,9 +4,11 @@
"compilerOptions": {
"allowJs": true,
"noEmit": false,
"emitDeclarationOnly": true,
"incremental": true,
"target": "ES2019",
"module": "ESNext",
"removeComments": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019"],
+16 -2
View File
@@ -29,15 +29,19 @@
"backstage-cli": "bin/backstage-cli"
},
"dependencies": {
"@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^3.18.5",
"@lerna/project": "^3.18.0",
"@rollup/plugin-commonjs": "^11.0.2",
"@rollup/plugin-json": "^4.0.2",
"@rollup/plugin-node-resolve": "^7.1.1",
"@spotify/web-scripts": "^6.0.0",
"@sucrase/webpack-loader": "^2.0.0",
"bfj": "^7.0.2",
"chalk": "^4.0.0",
"chokidar": "^3.3.1",
"commander": "^4.1.1",
"css-loader": "^3.5.3",
"dashify": "^2.0.0",
"diff": "^4.0.2",
"eslint-plugin-import": "^2.20.2",
@@ -50,27 +54,37 @@
"jest": "^25.1.0",
"jest-css-modules": "^2.1.0",
"jest-esm-transformer": "^1.0.0",
"mini-css-extract-plugin": "^0.9.0",
"ora": "^4.0.3",
"raw-loader": "^4.0.1",
"react": "^16.0.0",
"react-dev-utils": "^10.2.0",
"react-scripts": "^3.4.1",
"react-hot-loader": "^4.12.21",
"recursive-readdir": "^2.2.2",
"replace-in-file": "^6.0.0",
"rollup": "^2.3.2",
"rollup-plugin-dts": "^1.4.6",
"rollup-plugin-esbuild": "^1.4.1",
"rollup-plugin-image-files": "^1.4.2",
"rollup-plugin-peer-deps-external": "^2.2.2",
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-typescript2": "^0.26.0",
"style-loader": "^1.2.1",
"sucrase": "^3.14.1",
"tar": "^6.0.1",
"ts-loader": "^7.0.4",
"url-loader": "^4.1.0",
"webpack": "^4.41.6",
"webpack-dev-server": "^3.10.3"
"webpack-dev-server": "^3.10.3",
"yml-loader": "^2.1.0",
"yn": "^4.0.0"
},
"devDependencies": {
"@types/diff": "^4.0.2",
"@types/fs-extra": "^8.1.0",
"@types/html-webpack-plugin": "^3.2.2",
"@types/inquirer": "^6.5.0",
"@types/mini-css-extract-plugin": "^0.9.1",
"@types/node": "^13.7.2",
"@types/ora": "^3.2.0",
"@types/react-dev-utils": "^9.0.4",
+6 -9
View File
@@ -14,15 +14,12 @@
* limitations under the License.
*/
import { run } from '../../lib/run';
import { buildBundle } from '../../lib/bundler';
import { Command } from 'commander';
export default async () => {
const args = ['build'];
await run('react-scripts', args, {
env: {
EXTEND_ESLINT: 'true',
SKIP_PREFLIGHT_CHECK: 'true',
},
export default async (cmd: Command) => {
await buildBundle({
entry: 'src/index',
statsJsonEnabled: cmd.stats,
});
};
+8 -14
View File
@@ -14,20 +14,14 @@
* limitations under the License.
*/
import { run } from '../../lib/run';
import { createLogFunc } from '../../lib/logging';
import { watchDeps } from '../../lib/watchDeps';
import { serveBundle } from '../../lib/bundler';
import { Command } from 'commander';
export default async () => {
// Start dynamic watch and build of dependencies, then serve the app
await watchDeps({ build: true });
await run('react-scripts', ['start'], {
env: {
EXTEND_ESLINT: 'true',
SKIP_PREFLIGHT_CHECK: 'true',
},
// We need to avoid clearing the terminal, or the build feedback of dependencies will be lost
stdoutLogFunc: createLogFunc(process.stdout),
export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'src/index',
checksEnabled: cmd.check,
});
await waitForExit();
};
@@ -21,7 +21,7 @@ import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath } from 'path';
import os from 'os';
import { Task, templatingTask } from '../../lib/tasks';
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
const exec = promisify(execCb);
@@ -57,19 +57,22 @@ async function cleanUp(tempDir: string) {
});
}
async function buildApp(appFolder: string) {
const commands = ['yarn install', 'yarn build'];
for (const command of commands) {
await Task.forItem('executing', command, async () => {
process.chdir(appFolder);
async function buildApp(appDir: string) {
const runCmd = async (cmd: string) => {
await Task.forItem('executing', cmd, async () => {
process.chdir(appDir);
await exec(command).catch((error) => {
await exec(cmd).catch((error) => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(`Could not execute command ${chalk.cyan(command)}`);
throw new Error(`Could not execute command ${chalk.cyan(cmd)}`);
});
});
}
};
await installWithLocalDeps(appDir);
await runCmd('yarn tsc');
await runCmd('yarn build');
}
export async function moveApp(
@@ -86,44 +89,6 @@ export async function moveApp(
});
}
async function addPackageResolutions(appDir: string) {
const pkgJsonPath = resolvePath(appDir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
const depNames = [
'cli',
'core',
'dev-utils',
'test-utils',
'test-utils-core',
'theme',
];
for (const name of depNames) {
await Task.forItem(
'adding',
`@backstage/${name} link to package.json`,
async () => {
const pkgPath = paths.resolveOwnRoot('packages', name);
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
await fs
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
.catch((error) => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
export default async () => {
const questions: Question[] = [
{
@@ -164,12 +129,6 @@ export default async () => {
Task.section('Moving to final location');
await moveApp(tempDir, appDir, answers.name);
// e2e testing needs special treatment
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Linking packages locally for e2e tests');
await addPackageResolutions(appDir);
}
Task.section('Building the app');
await buildApp(appDir);
@@ -28,7 +28,7 @@ import {
} from '../../lib/codeowners';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
import { Task, templatingTask } from '../../lib/tasks';
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
const exec = promisify(execCb);
async function checkExists(rootDir: string, id: string) {
@@ -141,7 +141,9 @@ async function cleanUp(tempDir: string) {
}
async function buildPlugin(pluginFolder: string) {
const commands = ['yarn install', 'yarn build'];
await installWithLocalDeps(paths.targetRoot);
const commands = ['yarn tsc', 'yarn build'];
for (const command of commands) {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
+3 -80
View File
@@ -14,85 +14,8 @@
* limitations under the License.
*/
import { rollup, watch, OutputOptions } from 'rollup';
import { Command } from 'commander';
import chalk from 'chalk';
import { withCache, getDefaultCacheOptions } from '../../lib/buildCache';
import { paths } from '../../lib/paths';
import conf from './rollup.config';
import { buildPackage } from '../../lib/packager';
function logError(error: any) {
console.log('');
if (error.code === 'PLUGIN_ERROR') {
// typescript2 plugin has a complete message with all codeframes
if (error.plugin === 'rpt2') {
console.log(error.message);
} else {
// Log which plugin is causing errors to make it easier to identity.
// If we see these in logs we likely want to provide some custom error
// output for those plugins too.
console.log(`(plugin ${error.plugin}) ${error}`);
}
} else {
// Generic rollup errors, log what's available
if (error.loc) {
const file = `${paths.resolveTarget((error.loc.file || error.id)!)}`;
const pos = `${error.loc.line}:${error.loc.column}`;
console.log(`${file} [${pos}]`);
} else if (error.id) {
console.log(paths.resolveTarget(error.id));
}
console.log(String(error));
if (error.url) {
console.log(chalk.cyan(error.url));
}
if (error.frame) {
console.log(chalk.dim(error.frame));
}
}
}
export default async (cmd: Command) => {
if (cmd.watch) {
// We're not resolving this promise because watch() doesn't have any exit event.
// Instead we just wait until the user sends an interrupt signal.
await new Promise(() => {
const watcher = watch(conf);
watcher.on('event', (event) => {
// START — the watcher is (re)starting
// BUNDLE_START — building an individual bundle
// BUNDLE_END — finished building a bundle
// END — finished building all bundles
// ERROR — encountered an error while bundling
if (event.code === 'ERROR') {
logError(event.error);
} else if (event.code === 'BUNDLE_START') {
console.log(chalk.dim(`building ${event.input}`));
} else if (event.code === 'BUNDLE_END') {
const { input, duration } = event;
const s = (duration / 1000).toFixed(1);
console.log(chalk.green(`built ${input} in ${s}s`));
}
});
});
}
await withCache(getDefaultCacheOptions(), async () => {
try {
const bundle = await rollup({
input: conf.input,
plugins: conf.plugins,
});
await bundle.generate(conf.output as OutputOptions);
await bundle.write(conf.output as OutputOptions);
} catch (error) {
logError(error);
process.exit(1);
}
});
export default async () => {
await buildPackage();
};
@@ -1,63 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import typescript from 'rollup-plugin-typescript2';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import postcss from 'rollup-plugin-postcss';
import imageFiles from 'rollup-plugin-image-files';
import json from '@rollup/plugin-json';
import { RollupWatchOptions } from 'rollup';
import { paths } from '../../lib/paths';
export default {
input: 'src/index.ts',
output: {
file: 'dist/index.esm.js',
format: 'module',
},
plugins: [
peerDepsExternal({
includeDependencies: true,
}),
resolve({
mainFields: ['browser', 'module', 'main'],
}),
commonjs({
include: ['node_modules/**', '../../node_modules/**'],
exclude: ['**/*.stories.*', '**/*.test.*'],
}),
postcss(),
imageFiles(),
json(),
typescript({
include: `${paths.resolveTarget('src')}/**/*.{js,jsx,ts,tsx}`,
tsconfigOverride: {
// The dev folder is for the local plugin serve, ignore it in the build
// If we don't do this we get a folder structure similar to dist/{src,dev}/...
exclude: ['dev'],
compilerOptions: {
// Use absolute path to src dir as root for declarations, relying on the default
// seems to produce declaration maps that are relative to dist/ instead of src/
// Using a relative path like ../src doesn't work either becaus it will be used as is in subdirs.
sourceRoot: paths.resolveTarget('src'),
},
},
clean: true,
}),
],
} as RollupWatchOptions;
@@ -14,14 +14,14 @@
* limitations under the License.
*/
import { startDevServer } from './server';
import { watchDeps } from '../../../lib/watchDeps';
import { serveBundle } from '../../lib/bundler';
import { Command } from 'commander';
export default async () => {
await watchDeps({ build: true });
export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'dev/index',
checksEnabled: cmd.check,
});
await startDevServer();
// Wait for interrupt signal
await new Promise(() => {});
await waitForExit();
};
+3 -1
View File
@@ -30,11 +30,13 @@ const main = (argv: string[]) => {
program
.command('app:build')
.description('Build an app for a production release')
.option('--stats', 'Write bundle stats to output directory')
.action(actionHandler(() => require('./commands/app/build')));
program
.command('app:serve')
.description('Serve an app for local development')
.option('--check', 'Enable type checking and linting')
.action(actionHandler(() => require('./commands/app/serve')));
program
@@ -53,13 +55,13 @@ const main = (argv: string[]) => {
program
.command('plugin:build')
.option('--watch', 'Enable watch mode')
.description('Build a plugin')
.action(actionHandler(() => require('./commands/plugin/build')));
program
.command('plugin:serve')
.description('Serves the dev/ folder of a plugin')
.option('--check', 'Enable type checking and linting')
.action(actionHandler(() => require('./commands/plugin/serve')));
program
+112
View File
@@ -0,0 +1,112 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import yn from 'yn';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import webpack from 'webpack';
import {
measureFileSizesBeforeBuild,
printFileSizesAfterBuild,
} from 'react-dev-utils/FileSizeReporter';
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
import { createConfig } from './config';
import { BuildOptions } from './types';
import { resolveBundlingPaths } from './paths';
import chalk from 'chalk';
// TODO(Rugvip): Limits from CRA, we might want to tweak these though.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
export async function buildBundle(options: BuildOptions) {
const { statsJsonEnabled } = options;
const paths = resolveBundlingPaths(options);
const config = createConfig(paths, {
...options,
checksEnabled: false,
isDev: false,
});
const compiler = webpack(config);
const isCi = yn(process.env.CI, { default: false });
const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist);
await fs.emptyDir(paths.targetDist);
const { stats } = await build(compiler, isCi).catch((error) => {
console.log(chalk.red('Failed to compile.\n'));
throw new Error(`Failed to compile.\n${error.message || error}`);
});
if (statsJsonEnabled) {
// No @types/bfj
await require('bfj').write(
resolvePath(paths.targetDist, 'bundle-stats.json'),
stats.toJson(),
);
}
printFileSizesAfterBuild(
stats,
previousFileSizes,
paths.targetDist,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE,
);
}
async function build(compiler: webpack.Compiler, isCi: boolean) {
const stats = await new Promise<webpack.Stats>((resolve, reject) => {
compiler.run((err, buildStats) => {
if (err) {
if (err.message) {
const { errors } = formatWebpackMessages({
errors: [err.message],
warnings: new Array<string>(),
} as webpack.Stats.ToJsonOutput);
throw new Error(errors[0]);
} else {
reject(err);
}
} else {
resolve(buildStats);
}
});
});
const { errors, warnings } = formatWebpackMessages(
stats.toJson({ all: false, warnings: true, errors: true }),
);
if (errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
throw new Error(errors[0]);
}
if (isCi && warnings.length) {
console.log(
chalk.yellow(
'\nTreating warnings as errors because process.env.CI = true.\n',
),
);
throw new Error(warnings.join('\n\n'));
}
return { stats };
}
@@ -15,91 +15,28 @@
*/
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 { BundlingPaths } from './paths';
import { transforms } from './transforms';
import { optimization } from './optimization';
import { BundlingOptions } from './types';
// 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.targetPath,
entry: [
`${require.resolve('webpack-dev-server/client')}?/`,
require.resolve('webpack/hot/dev-server'),
paths.targetDevEntry,
],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
plugins: [
new ModuleScopePlugin(
[paths.targetSrc, paths.targetDev],
[paths.targetPackageJson],
),
],
},
module: {
rules: [
{
test: /\.(tsx?|jsx?|mjs)$/,
enforce: 'pre',
include: [paths.targetSrc, paths.targetDev],
use: {
loader: 'eslint-loader',
options: {
emitWarning: true,
},
},
},
{
test: /\.(tsx?|jsx?|mjs)$/,
include: [paths.targetSrc, paths.targetDev],
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.targetAssets,
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.targetHtml,
}),
export function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
): webpack.Configuration {
const { checksEnabled, isDev } = options;
const { plugins, loaders } = transforms(paths, options);
if (checksEnabled) {
plugins.push(
new ForkTsCheckerWebpackPlugin({
tsconfig: paths.targetTsConfig,
eslint: true,
@@ -111,8 +48,45 @@ export function createConfig(paths: Paths): webpack.Configuration {
},
reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
}),
new webpack.HotModuleReplacementPlugin(),
],
);
}
return {
mode: isDev ? 'development' : 'production',
profile: false,
bail: false,
performance: {
hints: false, // we check the gzip size instead
},
devtool: isDev ? 'cheap-module-eval-source-map' : 'source-map',
context: paths.targetPath,
entry: [require.resolve('react-hot-loader/patch'), paths.targetEntry],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
mainFields: ['main:src', 'browser', 'module', 'main'],
plugins: [
new ModuleScopePlugin(
[paths.targetSrc, paths.targetDev],
[paths.targetPackageJson],
),
],
alias: {
'react-dom': '@hot-loader/react-dom',
},
},
module: {
rules: loaders,
},
output: {
path: paths.targetDist,
publicPath: '/',
filename: isDev ? '[name].js' : '[name].[hash:8].js',
chunkFilename: isDev
? '[name].chunk.js'
: '[name].[chunkhash:8].chunk.js',
},
optimization: optimization(options),
plugins,
node: {
module: 'empty',
dgram: 'empty',
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { buildBundle } from './bundle';
export { serveBundle } from './server';
@@ -0,0 +1,67 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Options } from 'webpack';
import { BundlingOptions } from './types';
export const optimization = (
options: BundlingOptions,
): Options.Optimization => {
const { isDev } = options;
return {
minimize: !isDev,
runtimeChunk: 'single',
splitChunks: {
automaticNameDelimiter: '-',
cacheGroups: {
default: false,
// Put all vendor code needed for initial page load in individual files if they're big
// enough, if they're smaller they end up in the main
packages: {
chunks: 'initial',
test: /[\\/]node_modules[\\/]/,
name(module: any) {
// get the name. E.g. node_modules/packageName/not/this/part.js
// or node_modules/packageName
const packageName = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/,
)[1];
// npm package names are URL-safe, but some servers don't like @ symbols
return packageName.replace('@', '');
},
filename: isDev
? 'module-[name].js'
: 'module-[name].[chunkhash:8].js',
priority: 10,
minSize: 100000,
minChunks: 1,
maxAsyncRequests: Infinity,
maxInitialRequests: Infinity,
} as any, // filename is not included in type, but we need it
// Group together the smallest modules
vendor: {
chunks: 'initial',
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 5,
enforce: true,
},
},
},
};
};
@@ -15,9 +15,16 @@
*/
import { existsSync } from 'fs';
import { paths } from '../../../lib/paths';
import { paths } from '../paths';
export type BundlingPathsOptions = {
// bundle entrypoint, e.g. 'src/index'
entry: string;
};
export function resolveBundlingPaths(options: BundlingPathsOptions) {
const { entry } = options;
export function getPaths() {
const resolveTargetModule = (path: string) => {
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
const filePath = paths.resolveTarget(`${path}.${ext}`);
@@ -28,7 +35,7 @@ export function getPaths() {
return paths.resolveTarget(`${path}.js`);
};
let targetHtml = paths.resolveTarget('dev/index.html');
let targetHtml = paths.resolveTarget(`${entry}.html`);
if (!existsSync(targetHtml)) {
targetHtml = paths.resolveOwn('templates/serve_index.html');
}
@@ -36,14 +43,15 @@ export function getPaths() {
return {
targetHtml,
targetPath: paths.resolveTarget('.'),
targetDist: paths.resolveTarget('dist'),
targetAssets: paths.resolveTarget('assets'),
targetSrc: paths.resolveTarget('src'),
targetDev: paths.resolveTarget('dev'),
targetDevEntry: resolveTargetModule('dev/index'),
targetTsConfig: paths.resolveTarget('tsconfig.json'),
targetEntry: resolveTargetModule(entry),
targetTsConfig: paths.resolveTargetRoot('tsconfig.json'),
targetNodeModules: paths.resolveTarget('node_modules'),
targetPackageJson: paths.resolveTarget('package.json'),
};
}
export type Paths = ReturnType<typeof getPaths>;
export type BundlingPaths = ReturnType<typeof resolveBundlingPaths>;
@@ -14,33 +14,37 @@
* limitations under the License.
*/
import yn from 'yn';
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';
import { ServeOptions } from './types';
import { resolveBundlingPaths } from './paths';
export async function startDevServer() {
export async function serveBundle(options: ServeOptions) {
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;
throw new Error(`Invalid or no port set: '${port}'`);
}
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const protocol = yn(process.env.HTTPS, { default: false }) ? 'https' : 'http';
const urls = prepareUrls(protocol, host, port);
const paths = getPaths();
const config = createConfig(paths);
const paths = resolveBundlingPaths(options);
const config = createConfig(paths, { ...options, isDev: true });
const compiler = webpack(config);
const server = new WebpackDevServer(compiler, {
hot: true,
publicPath: '/',
historyApiFallback: true,
quiet: true,
clientLogLevel: 'warning',
stats: 'errors-warnings',
https: protocol === 'https',
host,
port,
@@ -57,4 +61,19 @@ export async function startDevServer() {
resolve();
});
});
const waitForExit = async () => {
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
server.close();
// exit instead of resolve. The process is shutting down and resolving a promise here logs an error
process.exit();
});
}
// Block indefinitely and wait for the interrupt signal
return new Promise(() => {});
};
return waitForExit;
}
+101
View File
@@ -0,0 +1,101 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import webpack, { Module, Plugin } from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { BundlingOptions } from './types';
import { BundlingPaths } from './paths';
type Transforms = {
loaders: Module['rules'];
plugins: Plugin[];
};
export const transforms = (
paths: BundlingPaths,
options: BundlingOptions,
): Transforms => {
const { isDev } = options;
const loaders = [
{
test: /\.(tsx?)$/,
exclude: /node_modules/,
loader: require.resolve('@sucrase/webpack-loader'),
options: {
transforms: ['typescript', 'jsx', 'react-hot-loader'],
},
},
{
test: /\.(jsx?|mjs)$/,
exclude: /node_modules/,
loader: require.resolve('@sucrase/webpack-loader'),
options: {
transforms: ['jsx', 'react-hot-loader'],
},
},
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
{
test: /\.ya?ml$/,
use: require.resolve('yml-loader'),
},
{
include: /\.(md)$/,
use: require.resolve('raw-loader'),
},
{
test: /\.css$/i,
use: [
isDev ? require.resolve('style-loader') : MiniCssExtractPlugin.loader,
{
loader: require.resolve('css-loader'),
options: {
sourceMap: true,
},
},
],
},
];
const plugins = new Array<Plugin>();
plugins.push(
new HtmlWebpackPlugin({
template: paths.targetHtml,
}),
);
if (isDev) {
plugins.push(new webpack.HotModuleReplacementPlugin());
} else {
plugins.push(
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
chunkFilename: '[name].[id].[contenthash:8].css',
}),
);
}
return { loaders, plugins };
};
+30
View File
@@ -0,0 +1,30 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BundlingPathsOptions } from './paths';
export type BundlingOptions = {
checksEnabled: boolean;
isDev: boolean;
};
export type ServeOptions = BundlingPathsOptions & {
checksEnabled: boolean;
};
export type BuildOptions = BundlingPathsOptions & {
statsJsonEnabled: boolean;
};
+81
View File
@@ -0,0 +1,81 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { relative as relativePath } from 'path';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import postcss from 'rollup-plugin-postcss';
import esbuild from 'rollup-plugin-esbuild';
import imageFiles from 'rollup-plugin-image-files';
import dts from 'rollup-plugin-dts';
import json from '@rollup/plugin-json';
import { RollupOptions } from 'rollup';
import { paths } from '../paths';
export const makeConfigs = async (): Promise<RollupOptions[]> => {
const typesInput = paths.resolveTargetRoot(
'dist',
relativePath(paths.targetRoot, paths.targetDir),
'src/index.d.ts',
);
const declarationsExist = await fs.pathExists(typesInput);
if (!declarationsExist) {
const path = relativePath(paths.targetDir, typesInput);
throw new Error(
`No declaration files found at ${path}, be sure to run tsc to generate .d.ts files before packaging`,
);
}
return [
{
input: 'src/index.ts',
output: {
file: 'dist/index.esm.js',
format: 'module',
},
plugins: [
peerDepsExternal({
includeDependencies: true,
}),
resolve({
mainFields: ['browser', 'module', 'main'],
}),
commonjs({
include: ['node_modules/**', '../../node_modules/**'],
exclude: ['**/*.stories.*', '**/*.test.*'],
}),
postcss(),
imageFiles(),
json(),
esbuild({
target: 'es2019',
}),
],
},
{
input: typesInput,
output: {
file: 'dist/index.d.ts',
format: 'es',
},
plugins: [dts()],
},
];
};
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { buildPackage } from './packager';
+86
View File
@@ -0,0 +1,86 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { rollup, RollupOptions } from 'rollup';
import chalk from 'chalk';
import { relative as relativePath } from 'path';
import { paths } from '../paths';
import { makeConfigs } from './config';
function formatErrorMessage(error: any) {
let msg = '';
if (error.code === 'PLUGIN_ERROR') {
if (error.plugin === 'esbuild') {
msg += `${error.message}\n\n`;
for (const { text, location } of error.errors) {
const { line, column } = location;
const path = relativePath(paths.targetDir, error.id);
const loc = chalk.cyan(`${path}:${line}:${column}`);
if (text === 'Unexpected "<"' && error.id.endsWith('.js')) {
msg += `${loc}: ${text}, JavaScript files with JSX should use a .jsx extension`;
} else {
msg += `${loc}: ${text}`;
}
}
} else {
// Log which plugin is causing errors to make it easier to identity.
// If we see these in logs we likely want to provide some custom error
// output for those plugins too.
msg += `(plugin ${error.plugin}) ${error}\n`;
}
} else {
// Generic rollup errors, log what's available
if (error.loc) {
const file = `${paths.resolveTarget((error.loc.file || error.id)!)}`;
const pos = `${error.loc.line}:${error.loc.column}`;
msg += `${file} [${pos}]\n`;
} else if (error.id) {
msg += `${paths.resolveTarget(error.id)}\n`;
}
msg += `${error}\n`;
if (error.url) {
msg += `${chalk.cyan(error.url)}\n`;
}
if (error.frame) {
msg += `${chalk.dim(error.frame)}\n`;
}
}
return msg;
}
async function build(config: RollupOptions) {
try {
const bundle = await rollup(config);
if (config.output) {
for (const output of [config.output].flat()) {
await bundle.generate(output);
await bundle.write(output);
}
}
} catch (error) {
throw new Error(formatErrorMessage(error));
}
}
export const buildPackage = async () => {
const configs = await makeConfigs();
await Promise.all(configs.map(build));
};
+104 -4
View File
@@ -18,8 +18,12 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
import { basename, dirname } from 'path';
import { resolve as resolvePath, basename, dirname } from 'path';
import recursive from 'recursive-readdir';
import { promisify } from 'util';
import { exec as execCb } from 'child_process';
import { paths } from './paths';
const exec = promisify(execCb);
const TASK_NAME_MAX_LENGTH = 14;
@@ -69,7 +73,7 @@ export async function templatingTask(
destinationDir: string,
context: any,
) {
const files = await recursive(templateDir).catch(error => {
const files = await recursive(templateDir).catch((error) => {
throw new Error(`Failed to read template directory: ${error.message}`);
});
@@ -85,7 +89,7 @@ export async function templatingTask(
const compiled = handlebars.compile(template.toString());
const contents = compiled({ name: basename(destination), ...context });
await fs.writeFile(destination, contents).catch(error => {
await fs.writeFile(destination, contents).catch((error) => {
throw new Error(
`Failed to create file: ${destination}: ${error.message}`,
);
@@ -93,7 +97,7 @@ export async function templatingTask(
});
} else {
await Task.forItem('copying', basename(file), async () => {
await fs.copyFile(file, destinationFile).catch(error => {
await fs.copyFile(file, destinationFile).catch((error) => {
const destination = destinationFile;
throw new Error(
`Failed to copy file to ${destination} : ${error.message}`,
@@ -103,3 +107,99 @@ export async function templatingTask(
}
}
}
// List of local packages that we need to modify as a part of an E2E test
const PATCH_PACKAGES = [
'cli',
'core',
'dev-utils',
'test-utils',
'test-utils-core',
'theme',
];
// This runs a `yarn install` task, but with special treatment for e2e tests
export async function installWithLocalDeps(dir: string) {
// This makes us install any package inside this repo as a local file dependency.
// For example, instead of trying to fetch @backstage/core from npm, we point it
// to <repo-root>/packages/core. This makes yarn use a simple file copy to install it instead.
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Linking packages locally for e2e tests');
const pkgJsonPath = resolvePath(dir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
if (!pkgJson.resolutions[`@backstage/${PATCH_PACKAGES[0]}`]) {
for (const name of PATCH_PACKAGES) {
await Task.forItem(
'adding',
`@backstage/${name} link to package.json`,
async () => {
const pkgPath = paths.resolveOwnRoot('packages', name);
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
await fs
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
.catch((error) => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
}
await Task.forItem('executing', 'yarn install', async () => {
await exec('yarn install', { cwd: dir }).catch((error) => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(
`Could not execute command ${chalk.cyan('yarn install')}`,
);
});
});
// This takes care of pointing all the installed packages from this repo to
// dist instead of the local src.
// For example node_modules/@backstage/core/packages.json is rewritten to point
// types to dist/index.d.ts and the main:src field is removed.
// Without this we get type checking errors in the e2e test
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Patchling local dependencies for e2e tests');
for (const name of PATCH_PACKAGES) {
await Task.forItem(
'patching',
`node_modules/@backstage/${name} package.json`,
async () => {
const depJsonPath = resolvePath(
dir,
'node_modules/@backstage',
name,
'package.json',
);
const depJson = await fs.readJson(depJsonPath);
// We want dist to be used for e2e tests
delete depJson['main:src'];
depJson.types = 'dist/index.d.ts';
await fs
.writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 })
.catch((error) => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
}
@@ -7,14 +7,18 @@
},
"scripts": {
"start": "yarn workspace app start",
"bundle": "yarn build && yarn workspace app bundle",
"bundle": "yarn workspace app bundle",
"build": "lerna run build",
"test": "yarn build && lerna run test --since origin/master -- --coverage",
"test:all": "yarn build && lerna run test -- --coverage",
"tsc": "tsc",
"clean": "backstage-cli clean && lerna run clean",
"diff": "lerna run diff --",
"test": "lerna run test --since origin/master -- --coverage",
"test:all": "lerna run test -- --coverage",
"lint": "lerna run lint --since origin/master --",
"lint:all": "lerna run lint --",
"create-plugin": "backstage-cli create-plugin",
"clean": "lerna run clean"
"remove-plugin": "backstage-cli remove-plugin",
"postinstall": "patch-package"
},
"workspaces": {
"packages": [
@@ -25,6 +29,7 @@
"devDependencies": {
"@backstage/cli": "^{{version}}",
"lerna": "^3.20.2",
"patch-package": "^6.2.2",
"prettier": "^1.19.1"
}
}
@@ -18,7 +18,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
@@ -0,0 +1,11 @@
# patches
This folder contains patches for dependency type declarations. Typescript doesn't provide a way to override of fix types that are installed as a part of the dependent package itself.
Do not add any more patches here, these patches were added as a part of getting the entire repo type-checked, and we depended on some packages with bad types.
As soon as the below issues are fixed, we should remove the patching functionality completely.
## material-table
Fixed in master but not released yet, tracked here: https://github.com/mbrn/material-table/pull/1624
@@ -0,0 +1,12 @@
diff --git a/node_modules/material-table/types/index.d.ts b/node_modules/material-table/types/index.d.ts
index 06b700b..5b3c765 100644
--- a/node_modules/material-table/types/index.d.ts
+++ b/node_modules/material-table/types/index.d.ts
@@ -228,7 +228,6 @@ export interface Options {
showTitle?: boolean;
showTextRowsSelected?: boolean;
search?: boolean;
- searchText?: string;
searchFieldAlignment?: 'left' | 'right';
searchFieldStyle?: React.CSSProperties;
searchText?: string;
@@ -2,6 +2,7 @@
"name": "plugin-welcome",
"version": "0.0.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"private": true,
"scripts": {
@@ -1,3 +1,8 @@
{
"extends": "@backstage/cli/config/tsconfig.json"
"extends": "@backstage/cli/config/tsconfig.json",
"include": ["packages/*/src", "plugins/*/src", "plugins/*/dev"],
"exclude": ["**/node_modules"],
"compilerOptions": {
"outDir": "dist"
}
}
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-{{id}}",
"version": "{{version}}",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
@@ -33,7 +34,7 @@
"@backstage/dev-utils": "^{{version}}",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "dev"],
"compilerOptions": {}
}
+4 -1
View File
@@ -1,8 +1,11 @@
{
"extends": "./tsconfig.json",
"extends": "./config/tsconfig.json",
"include": ["src"],
"exclude": ["**/*.test.*"],
"compilerOptions": {
"outDir": "dist",
"emitDeclarationOnly": false,
"removeComments": true,
"module": "CommonJS"
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {}
}