Build: Enable long term caching for frontend assets (#47625)

* build(webpack): move CopyUniconsPlugin into own file

* chore(webpack): delete unused blobUrl and compile loaders

* build(webpack): prefer contenthash over fullhash for longer caching

* build(webpack): set optimization.moduleIds named only in dev

* build(webpack): introduce HTMLWebpackCSSChunks so templates can access theme css by name

* feat: inject css files with contenthash in html templates

* revert(error-template): remove ContentDeliveryURL from CSS href

* refactor(index-template): update grafanaBootData.themePaths

* chore(webpack): add typescript annotations for CopyUniconsPlugin
This commit is contained in:
Jack Westbrook 2022-05-26 11:49:18 +02:00 committed by GitHub
parent 20a83ba14f
commit 78bef7a26a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 108 additions and 154 deletions

View File

@ -10,7 +10,11 @@
<base href="[[.AppSubUrl]]/" />
<link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].<%= compilation.hash %>.css" />
[[ if eq .Theme "light" ]]
<link rel="stylesheet" href="public/build/<%= htmlWebpackPlugin.files.cssChunks.light %>" />
[[ else ]]
<link rel="stylesheet" href="public/build/<%= htmlWebpackPlugin.files.cssChunks.dark %>" />
[[ end ]]
<link rel="icon" type="image/png" href="public/img/fav32.png" />
<link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28" />

View File

@ -20,10 +20,12 @@
<link rel="icon" type="image/png" href="[[.FavIcon]]" />
<link rel="apple-touch-icon" sizes="180x180" href="[[.AppleTouchIcon]]" />
<link rel="mask-icon" href="[[.ContentDeliveryURL]]public/img/grafana_mask_icon.svg" color="#F05A28" />
<link
rel="stylesheet"
href="[[.ContentDeliveryURL]]public/build/grafana.[[ .Theme ]].<%= compilation.hash %>.css"
/>
[[ if eq .Theme "light" ]]
<link rel="stylesheet" href="[[.ContentDeliveryURL]]public/build/<%= htmlWebpackPlugin.files.cssChunks.light %>" />
[[ else ]]
<link rel="stylesheet" href="[[.ContentDeliveryURL]]public/build/<%= htmlWebpackPlugin.files.cssChunks.dark %>" />
[[ end ]]
<script nonce="[[.Nonce]]">
performance.mark('frontend_boot_css_time_seconds');
@ -251,8 +253,8 @@
settings: [[.Settings]],
navTree: [[.NavTree]],
themePaths: {
light: '[[.ContentDeliveryURL]]public/build/grafana.light.<%= compilation.hash %>.css',
dark: '[[.ContentDeliveryURL]]public/build/grafana.dark.<%= compilation.hash %>.css'
light: '[[.ContentDeliveryURL]]public/build/<%= htmlWebpackPlugin.files.cssChunks.light %>',
dark: '[[.ContentDeliveryURL]]public/build/<%= htmlWebpackPlugin.files.cssChunks.dark %>'
}
};

View File

@ -1,8 +0,0 @@
const loaderUtils = require('loader-utils');
module.exports = function blobUrl(source) {
const { type } = loaderUtils.getOptions(this) || {};
return `module.exports = URL.createObjectURL(new Blob([${JSON.stringify(source)}]${
type ? `, { type: ${JSON.stringify(type)} }` : ''
}));`;
};

View File

@ -1,100 +0,0 @@
const loaderUtils = require('loader-utils');
const ExternalsPlugin = require('webpack/lib/ExternalsPlugin');
const LoaderTargetPlugin = require('webpack/lib/LoaderTargetPlugin');
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
const NodeTargetPlugin = require('webpack/lib/node/NodeTargetPlugin');
const WebWorkerTemplatePlugin = require('webpack/lib/webworker/WebWorkerTemplatePlugin');
const COMPILATION_METADATA = Symbol('COMPILATION_METADATA');
module.exports.COMPILATION_METADATA = COMPILATION_METADATA;
module.exports.pitch = function pitch(remainingRequest) {
const { target, plugins = [], output, emit } = loaderUtils.getOptions(this) || {};
if (target !== 'worker') {
throw new Error(`Unsupported compile target: ${JSON.stringify(target)}`);
}
this.cacheable(false);
const { filename, options = {} } = getOutputFilename(output, { target });
// eslint-disable-next-line no-underscore-dangle
const currentCompilation = this._compilation;
const outputFilename = loaderUtils.interpolateName(this, filename, {
context: options.context || currentCompilation.options.context,
regExp: options.regExp,
});
const outputOptions = {
filename: outputFilename,
chunkFilename: `${outputFilename}.[id]`,
namedChunkFilename: null,
};
const compilerOptions = currentCompilation.compiler.options;
const childCompiler = currentCompilation.createChildCompiler('worker', outputOptions, [
// https://github.com/webpack/webpack/blob/master/lib/WebpackOptionsApply.js
new WebWorkerTemplatePlugin(outputOptions),
new LoaderTargetPlugin('webworker'),
...(this.target === 'web' || this.target === 'webworker' ? [] : [new NodeTargetPlugin()]),
// https://github.com/webpack-contrib/worker-loader/issues/95#issuecomment-352856617
...(compilerOptions.externals ? [new ExternalsPlugin(compilerOptions.externals)] : []),
...plugins,
new SingleEntryPlugin(this.context, `!!${remainingRequest}`, 'main'),
]);
const subCache = `subcache ${__dirname} ${remainingRequest}`;
childCompiler.plugin('compilation', (compilation) => {
if (!compilation.cache) {
return;
}
if (!(subCache in compilation.cache)) {
Object.assign(compilation.cache, { [subCache]: {} });
}
Object.assign(compilation, { cache: compilation.cache[subCache] });
});
const callback = this.async();
childCompiler.runAsChild((error, entries, compilation) => {
if (error) {
return callback(error);
}
if (entries.length === 0) {
return callback(null, null);
}
const mainFilename = entries[0].files[0];
if (emit === false) {
delete currentCompilation.assets[mainFilename];
}
callback(null, compilation.assets[mainFilename].source(), null, {
[COMPILATION_METADATA]: entries[0].files,
});
});
};
function getOutputFilename(options, { target }) {
if (!options) {
return { filename: `[fullhash].${target}.js`, options: undefined };
}
if (typeof options === 'string') {
return { filename: options, options: undefined };
}
if (typeof options === 'object') {
return {
filename: options.filename,
options: {
context: options.context,
regExp: options.regExp,
},
};
}
throw new Error(`Invalid compile output options: ${options}`);
}

View File

@ -0,0 +1,39 @@
const fs = require('fs-extra');
const path = require('path');
class CopyUniconsPlugin {
/**
* @param {import('webpack').Compiler} compiler
*/
apply(compiler) {
compiler.hooks.afterEnvironment.tap(
'CopyUniconsPlugin',
/**
* @param {import('webpack').Compilation} compilation
*/
() => {
let destDir = path.resolve(__dirname, '../../../public/img/icons/unicons');
if (!fs.pathExistsSync(destDir)) {
let srcDir = path.join(
path.dirname(require.resolve('iconscout-unicons-tarball/package.json')),
'unicons/svg/line'
);
fs.copySync(srcDir, destDir);
}
let solidDestDir = path.resolve(__dirname, '../../../public/img/icons/solid');
if (!fs.pathExistsSync(solidDestDir)) {
let srcDir = path.join(
path.dirname(require.resolve('iconscout-unicons-tarball/package.json')),
'unicons/svg/solid'
);
fs.copySync(srcDir, solidDestDir);
}
}
);
}
}
module.exports = CopyUniconsPlugin;

View File

@ -0,0 +1,42 @@
const HtmlWebpackPlugin = require('html-webpack-plugin');
/*
* This plugin returns the css associated with entrypoints. Those chunks can be found
* in `htmlWebpackPlugin.files.cssChunks`.
* The HTML Webpack plugin removed the chunks object in v5 in favour of an array however if we want
* to do anything smart with hashing (e.g. [contenthash]) we need a map of { themeName: chunkNameWithHash }.
*/
class HTMLWebpackCSSChunks {
/**
* @param {import('webpack').Compiler} compiler
*/
apply(compiler) {
compiler.hooks.compilation.tap(
'HTMLWebpackCSSChunks',
/**
* @param {import('webpack').Compilation} compilation
*/
(compilation) => {
HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
'HTMLWebpackCSSChunks',
(data, cb) => {
data.assets.cssChunks = {};
for (const entryPoint of compilation.entrypoints.values()) {
for (const chunk of entryPoint.chunks) {
const cssFile = [...chunk.files].find((file) => file.endsWith('.css'));
if (cssFile !== undefined) {
data.assets.cssChunks[chunk.name] = cssFile;
}
}
}
cb(null, data);
}
);
}
);
}
}
module.exports = HTMLWebpackCSSChunks;

View File

@ -1,36 +1,10 @@
const CopyWebpackPlugin = require('copy-webpack-plugin');
const fs = require('fs-extra');
const path = require('path');
const webpack = require('webpack');
const CopyUniconsPlugin = require('./plugins/CopyUniconsPlugin');
const CorsWorkerPlugin = require('./plugins/CorsWorkerPlugin');
class CopyUniconsPlugin {
apply(compiler) {
compiler.hooks.afterEnvironment.tap('CopyUniconsPlugin', () => {
let destDir = path.resolve(__dirname, '../../public/img/icons/unicons');
if (!fs.pathExistsSync(destDir)) {
let srcDir = path.join(
path.dirname(require.resolve('iconscout-unicons-tarball/package.json')),
'unicons/svg/line'
);
fs.copySync(srcDir, destDir);
}
let solidDestDir = path.resolve(__dirname, '../../public/img/icons/solid');
if (!fs.pathExistsSync(solidDestDir)) {
let srcDir = path.join(
path.dirname(require.resolve('iconscout-unicons-tarball/package.json')),
'unicons/svg/solid'
);
fs.copySync(srcDir, solidDestDir);
}
});
}
}
module.exports = {
target: 'web',
entry: {
@ -39,16 +13,14 @@ module.exports = {
output: {
clean: true,
path: path.resolve(__dirname, '../../public/build'),
filename: '[name].[fullhash].js',
filename: '[name].[contenthash].js',
// Keep publicPath relative for host.com/grafana/ deployments
publicPath: 'public/build/',
},
resolve: {
extensions: ['.ts', '.tsx', '.es6', '.js', '.json', '.svg'],
alias: {
// storybook v6 bump caused the app to bundle multiple versions of react breaking hooks
// make sure to resolve only from the project: https://github.com/facebook/react/issues/13991#issuecomment-435587809
// some of data source pluginis use global Prism object to add the language definition
// some of data source plugins use global Prism object to add the language definition
// we want to have same Prism object in core and in grafana/ui
prismjs: require.resolve('prismjs'),
},
@ -134,13 +106,12 @@ module.exports = {
{
test: /\.(svg|ico|jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/,
loader: 'file-loader',
options: { name: 'static/img/[name].[hash:8].[ext]' },
options: { name: 'static/img/[name].[contenthash:8].[ext]' },
},
],
},
// https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-3
optimization: {
moduleIds: 'named',
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',

View File

@ -8,6 +8,7 @@ const path = require('path');
const { DefinePlugin } = require('webpack');
const { merge } = require('webpack-merge');
const HTMLWebpackCSSChunks = require('./plugins/HTMLWebpackCSSChunks');
const common = require('./webpack.common.js');
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
@ -51,11 +52,11 @@ module.exports = (env = {}) =>
// https://webpack.js.org/guides/build-performance/#output-without-path-info
output: {
pathinfo: false,
filename: '[name].js',
},
// https://webpack.js.org/guides/build-performance/#avoid-extra-optimization-steps
optimization: {
moduleIds: 'named',
runtimeChunk: true,
removeAvailableModules: false,
removeEmptyChunks: false,
@ -91,7 +92,7 @@ module.exports = (env = {}) =>
extensions: ['.ts', '.tsx'],
}),
new MiniCssExtractPlugin({
filename: 'grafana.[name].[fullhash].css',
filename: 'grafana.[name].[contenthash].css',
}),
new HtmlWebpackPlugin({
filename: path.resolve(__dirname, '../../public/views/error.html'),
@ -103,11 +104,11 @@ module.exports = (env = {}) =>
new HtmlWebpackPlugin({
filename: path.resolve(__dirname, '../../public/views/index.html'),
template: path.resolve(__dirname, '../../public/views/index-template.html'),
hash: true,
inject: false,
chunksSortMode: 'none',
excludeChunks: ['dark', 'light'],
}),
new HTMLWebpackCSSChunks(),
new DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('development'),

View File

@ -7,6 +7,7 @@ const path = require('path');
const { DefinePlugin } = require('webpack');
const { merge } = require('webpack-merge');
const HTMLWebpackCSSChunks = require('./plugins/HTMLWebpackCSSChunks');
const common = require('./webpack.common.js');
module.exports = merge(common, {
@ -77,16 +78,16 @@ module.exports = merge(common, {
plugins: [
new MiniCssExtractPlugin({
filename: 'grafana.[name].[fullhash].css',
filename: 'grafana.[name].[contenthash].css',
}),
new HtmlWebpackPlugin({
filename: path.resolve(__dirname, '../../public/views/index.html'),
template: path.resolve(__dirname, '../../public/views/index-template.html'),
hash: true,
inject: false,
chunksSortMode: 'none',
excludeChunks: ['dark', 'light'],
}),
new HTMLWebpackCSSChunks(),
new ReactRefreshWebpackPlugin(),
new DefinePlugin({
'process.env': {

View File

@ -7,6 +7,7 @@ const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const { merge } = require('webpack-merge');
const HTMLWebpackCSSChunks = require('./plugins/HTMLWebpackCSSChunks');
const common = require('./webpack.common.js');
module.exports = (env = {}) =>
@ -63,7 +64,7 @@ module.exports = (env = {}) =>
plugins: [
new MiniCssExtractPlugin({
filename: 'grafana.[name].[fullhash].css',
filename: 'grafana.[name].[contenthash].css',
}),
new HtmlWebpackPlugin({
filename: path.resolve(__dirname, '../../public/views/error.html'),
@ -79,6 +80,7 @@ module.exports = (env = {}) =>
excludeChunks: ['manifest', 'dark', 'light'],
chunksSortMode: 'none',
}),
new HTMLWebpackCSSChunks(),
function () {
this.hooks.done.tap('Done', function (stats) {
if (stats.compilation.errors && stats.compilation.errors.length) {