atlassian-webresource-webpack-plugin
Version:
Auto-generates web-resource definitions from your webpacked code, for usage in an Atlassian product or plugin.
488 lines (485 loc) • 26.3 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const assert_1 = __importDefault(require("assert"));
const crypto_1 = require("crypto");
const fs_1 = require("fs");
const flatMap_1 = __importDefault(require("lodash/flatMap"));
const isObject_1 = __importDefault(require("lodash/isObject"));
const once_1 = __importDefault(require("lodash/once"));
const unionBy_1 = __importDefault(require("lodash/unionBy"));
const uniq_1 = __importDefault(require("lodash/uniq"));
const path_1 = __importDefault(require("path"));
const pretty_data_1 = require("pretty-data");
const url_join_1 = __importDefault(require("url-join"));
const uuid_1 = require("uuid");
const AppResourcesFactory_1 = __importDefault(require("./AppResourcesFactory"));
const base_dependencies_1 = require("./deps/base-dependencies");
const provided_dependencies_1 = require("./deps/provided-dependencies");
const file_system_1 = require("./helpers/file-system");
const options_parser_1 = require("./helpers/options-parser");
const web_resource_generator_1 = require("./helpers/web-resource-generator");
const logger_1 = require("./logger");
const runtime_load_shim_1 = __importDefault(require("./shims/runtime-load-shim"));
const ProvidedExternalDependencyModule_1 = __importDefault(require("./webpack-modules/ProvidedExternalDependencyModule"));
const WrmDependencyModule_1 = __importDefault(require("./webpack-modules/WrmDependencyModule"));
const WrmResourceModule_1 = __importDefault(require("./webpack-modules/WrmResourceModule"));
const WebpackHelpers_1 = require("./WebpackHelpers");
const WebpackRuntimeHelpers_1 = require("./WebpackRuntimeHelpers");
const WrmManifestPlugin_1 = __importDefault(require("./WrmManifestPlugin"));
const defaultResourceParams = new Map().set('svg', [
{
name: 'content-type',
value: 'image/svg+xml',
},
]);
const defaultTransformations = new Map().set('js', ['jsI18n']).set('soy', ['soyTransformer', 'jsI18n']);
const DEFAULT_DEV_ASSETS_HASH = 'DEV_PSEUDO_HASH';
const ASSOCIATIONS_PATH_IN_BUNDLE = './META-INF/fe-manifest-associations';
class WrmPlugin {
/**
* A Webpack plugin that takes the compilation tree and creates <web-resource> XML definitions that mirror the
* dependency graph.
*
* This plugin will:
*
* - generate <web-resource> definitions for each entrypoint, along with additional <web-resource> definitions for
* and appropriate dependencies on all chunks generated during compilation.
* - Add <dependency> declarations to each generated <web-resource> as appropriate, both for internal and external
* dependencies in the graph.
* - Add appropriate metadata to the <web-resource> definition, such as appropriate <context>s,
* enabled/disabled state, and more.
* @param {Options} options
*/
constructor(options) {
(0, assert_1.default)(options.pluginKey, `Option [String] "pluginKey" not specified. You must specify a valid fully qualified plugin key. e.g.: com.atlassian.jira.plugins.my-jira-plugin`);
(0, assert_1.default)(options.xmlDescriptors, `Option [String] "xmlDescriptors" not specified. You must specify the path to the directory where this plugin stores the descriptors about this plugin, used by the WRM to load your frontend code. This should point somewhere in the "target/classes" directory.`);
(0, assert_1.default)(path_1.default.isAbsolute(options.xmlDescriptors), `Option [String] "xmlDescriptors" must be absolute!`);
// pull out our options
this.options = Object.assign({
addAsyncNameAsContext: false,
addEntrypointNameAsContext: false,
noWRM: false,
useDocumentWriteInWatchMode: false,
verbose: false,
watch: false,
watchPrepare: false,
conditionMap: new Map(),
contextMap: new Map(),
dataProvidersMap: new Map(),
deprecatedEntrypoints: new Map(),
providedDependencies: new Map(),
webresourceKeyMap: new Map(),
resourceParamMap: defaultResourceParams,
transformationMap: defaultTransformations,
devAssetsHash: DEFAULT_DEV_ASSETS_HASH,
locationPrefix: '',
}, options);
(0, logger_1.setVerbose)(this.options.verbose);
this.options.locationPrefix = (0, options_parser_1.extractPathPrefixForXml)(this.options.locationPrefix);
// convert various maybe-objects to maps
this.options.conditionMap = (0, options_parser_1.toMap)(this.options.conditionMap);
this.options.contextMap = (0, options_parser_1.toMap)(this.options.contextMap);
this.options.webresourceKeyMap = (0, options_parser_1.toMap)(this.options.webresourceKeyMap);
// make sure various maps contain only unique items
this.options.resourceParamMap = this.ensureResourceParamsAreUnique((0, options_parser_1.toMap)(this.options.resourceParamMap));
this.options.transformationMap = this.ensureTransformationsAreUnique((0, options_parser_1.toMap)(this.options.transformationMap));
this.options.providedDependencies = this.ensureProvidedDependenciesAreUnique((0, options_parser_1.toMap)(this.options.providedDependencies));
this.options.dataProvidersMap = this.ensureDataProvidersMapIsValid((0, options_parser_1.toMap)(this.options.dataProvidersMap));
this.options.deprecatedEntrypoints = (0, options_parser_1.toMap)(this.options.deprecatedEntrypoints);
this.getAssetsUUID = (0, once_1.default)(this.getAssetsUUID.bind(this));
}
static extendTransformations(values) {
return [defaultTransformations, (0, options_parser_1.toMap)(values)].reduce((acc, map) => {
for (const [key, val] of map.entries()) {
const oldVals = acc.get(key);
const newVals = [].concat(oldVals).concat(val).filter(Boolean);
acc.set(key, newVals);
}
return acc;
}, new Map());
}
/**
* Generate an asset uuid per build - this is used to ensure we have a new "cache" for our assets per build.
* As JIRA-Server does not "rebuild" too often, this can be considered reasonable.
*/
getAssetsUUID(isProduction) {
return isProduction ? (0, uuid_1.v4)() : this.options.devAssetsHash;
}
ensureTransformationsAreUnique(transformations) {
transformations.forEach((val, key, map) => {
const values = [].concat(val).filter(Boolean);
map.set(key, (0, uniq_1.default)(values));
});
return transformations;
}
ensureResourceParamsAreUnique(params) {
params.forEach((val, key, map) => {
const values = [].concat(val).filter(Boolean);
map.set(key, (0, unionBy_1.default)(values.reverse(), 'name').reverse());
});
return params;
}
ensureProvidedDependenciesAreUnique(providedDependencies) {
const result = new Map(provided_dependencies_1.builtInProvidedDependencies);
for (const [name, providedDependency] of providedDependencies) {
if (result.has(name)) {
continue;
}
result.set(name, providedDependency);
}
(0, logger_1.log)('Using provided dependencies', Array.from(result));
return result;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
ensureDataProvidersMapIsValid(dataProvidersMap) {
const map = new Map();
const requiredKeys = ['key', 'class'];
for (const [entryPoint, dataProviders] of dataProvidersMap) {
if (!Array.isArray(dataProviders)) {
(0, logger_1.error)(`The value of data providers for "${entryPoint}" entry point should be an array of data providers.`, {
entryPoint,
dataProviders,
});
continue;
}
const validDataProviders = [];
for (const dataProvider of dataProviders) {
const keys = (0, isObject_1.default)(dataProvider) ? Object.keys(dataProvider) : [];
const isValidShape = requiredKeys.every((key) => keys.includes(key));
if (!isValidShape) {
(0, logger_1.error)(`The data provider shape for "${entryPoint}" entry point doesn't include required keys: ${requiredKeys.concat(', ')}.`, { entryPoint, dataProvider });
continue;
}
const { key, class: providerClass } = dataProvider;
if (!key || !providerClass) {
(0, logger_1.error)(`The data provider shape for "${entryPoint}" entry point contains missing or empty values.`, {
entryPoint,
key,
class: providerClass,
});
continue;
}
validDataProviders.push({
key,
class: providerClass,
});
}
if (validDataProviders.length) {
map.set(entryPoint, validDataProviders);
}
}
return map;
}
checkConfig(compiler) {
const ensureJsonPFunction = () => {
const outputOptions = compiler.options.output;
// @ts-expect-error TODO: Check if this is still valid with Webpack 5 as types suggest otherwise
const { jsonpFunction } = outputOptions;
if (!jsonpFunction || jsonpFunction === 'webpackJsonp') {
const generatedJsonpFunction = `atlassianWebpackJsonp${(0, crypto_1.createHash)('md5')
.update(this.options.pluginKey, 'utf8')
.digest('hex')}`;
(0, logger_1.warn)(`
*********************************************************************************
The output.jsonpFunction is not specified. This needs to be done to prevent clashes.
An automated jsonpFunction name for this plugin was created:
"${generatedJsonpFunction}"
*********************************************************************************
`);
// @ts-expect-error TODO: Check if this is still valid with Webpack 5 as types suggest otherwise
outputOptions.jsonpFunction = generatedJsonpFunction;
}
};
const ensureDevToolsAreDisabledForPrepare = () => {
const devToolOption = compiler.options.devtool;
if (this.options.watchPrepare && devToolOption) {
(0, logger_1.error)(`Having "devtool" option set to anything but "false" during the "watch-prepare" is invalid. It was set to: "${devToolOption}".`);
compiler.options.devtool = false;
// report issue to webpack
compiler.hooks.thisCompilation.tap('WarnNoDevToolInWatchPrepare', (compilation) => {
compilation.warnings.push(
// eslint-disable-next-line @typescript-eslint/no-var-requires
new (require('webpack/lib/WebpackError'))(`Having "devtool" option set to anything but "false" during the "watch-prepare" is invalid. It was set to: "${devToolOption}".`));
});
}
};
const ensureOutputPath = () => {
const outputPath = compiler.options.output.path;
if (!outputPath) {
throw new Error(`No "output.path" specified in your webpack configuration. This is required! Stopping.`);
}
};
const ensurePackageName = () => {
if (!this.options.packageName) {
let packageName;
// Find package closest to context
let directory = compiler.context;
while (directory !== path_1.default.parse(directory).root) {
const packageJsonPath = path_1.default.join(directory, 'package.json');
if ((0, fs_1.existsSync)(packageJsonPath)) {
const packageJson = JSON.parse((0, fs_1.readFileSync)(packageJsonPath, 'utf8'));
packageName = packageJson.name;
break;
}
directory = path_1.default.dirname(directory);
}
if (!packageName) {
throw new Error(`WrmPlugin expects Webpack context to be NPM package. Current context ${compiler.context} is not an NPM package.`);
}
this.options.packageName = packageName;
}
};
const ensureMiniCssExtractPluginHasDisabledRuntime = () => {
const plugins = compiler.options.plugins;
const miniCssExtractPlugin = plugins.find(
// we don't want to lock this check to a specific MiniCssExtractPlugin version
// so we can't use instanceof
(plugin) => { var _a; return plugin && ((_a = plugin.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'MiniCssExtractPlugin'; });
if (miniCssExtractPlugin && 'options' in miniCssExtractPlugin && miniCssExtractPlugin.options.runtime !== false) {
(0, logger_1.warn)(`
*********************************************************************************
MiniCssExtractPlugin runtime option needs to be disabled so that it doesn't
interfere with Web Resource Manager runtime.
*********************************************************************************
`);
// report issue to webpack
compiler.hooks.thisCompilation.tap('WarnMiniCssExtractPluginRuntimeEnabled', (compilation) => {
compilation.warnings.push(
// eslint-disable-next-line @typescript-eslint/no-var-requires
new (require('webpack/lib/WebpackError'))(`MiniCssExtractPlugin runtime option needs to be disabled so that it doesn't interfere with Web Resource Manager runtime.`));
});
}
};
compiler.hooks.afterEnvironment.tap('Check Config', () => {
ensureJsonPFunction();
ensureDevToolsAreDisabledForPrepare();
ensureOutputPath();
ensurePackageName();
ensureMiniCssExtractPluginHasDisabledRuntime();
});
}
overwritePublicPath(compiler) {
const isProductionMode = (0, WebpackHelpers_1.isRunningInProductionMode)(compiler);
compiler.hooks.compilation.tap('OverwritePublicPath Compilation', (compilation) => {
compilation.mainTemplate.hooks.requireExtensions.tap('OverwritePublicPath Require-Extensions', (standardScript) => {
// Ensure the `AJS.contextPath` function is available at runtime.
(0, base_dependencies_1.addBaseDependency)('com.atlassian.plugins.atlassian-plugins-webresource-plugin:context-path');
const uuid = this.getAssetsUUID(isProductionMode);
const assetWebresource = `${this.options.pluginKey}:assets-${uuid}`;
// Add the public path extension to the webpack module runtime.
return `${standardScript}
if (typeof AJS !== "undefined") {
__webpack_require__.p = AJS.contextPath() + "/s/${uuid}/_/download/resources/${assetWebresource}/";
}
`;
});
});
}
hookUpProvidedDependencies(compiler) {
(0, WebpackRuntimeHelpers_1.hookIntoNormalModuleFactory)('wrm plugin - provided dependencies', compiler, (factory) => (data, callback) => {
const { target } = (0, WebpackHelpers_1.extractLibraryDetailsFromWebpackConfig)(compiler);
const request = data.dependencies[0].request;
// get globally available libraries through wrm
if (this.options.providedDependencies.has(request)) {
(0, logger_1.log)('plugging hole into request to %s, will be provided as a dependency through WRM', request);
const p = this.options.providedDependencies.get(request);
callback(null, new ProvidedExternalDependencyModule_1.default(p.import, p.dependency, target));
return;
}
return factory(data, callback);
});
}
injectWRMSpecificRequestTypes(compiler) {
(0, WebpackRuntimeHelpers_1.hookIntoNormalModuleFactory)('wrm plugin - inject request types', compiler, (factory) => (data, callback) => {
const { target } = (0, WebpackHelpers_1.extractLibraryDetailsFromWebpackConfig)(compiler);
const request = data.dependencies[0].request;
// import web-resources we find static import statements for
if (request.startsWith('wr-dependency!')) {
const res = request.substr('wr-dependency!'.length);
(0, logger_1.log)('adding %s as a web-resource dependency through WRM', res);
callback(null, new WrmDependencyModule_1.default(res, target, this.options.pluginKey));
return;
}
// import resources we find static import statements for
if (request.startsWith('wr-resource!')) {
const res = request.substr('wr-resource!'.length);
(0, logger_1.log)('adding %s as a resource through WRM', res);
callback(null, new WrmResourceModule_1.default(res, target, data.context, compiler.options.context));
return;
}
return factory(data, callback);
});
}
/**
* Ensure the WRM.require function is available at runtime and is used to load any code-split chunks.
*/
enableAsyncLoadingWithWRM(compiler) {
compiler.hooks.compilation.tap('enable async loading with wrm - compilation', (compilation) => {
compilation.mainTemplate.hooks.jsonpScript.tap('enable async loading with wrm - jsonp-script', (source, chunk) => {
// TODO: understand how to set this data on chunk "properly" so that
// our normalModuleFactory hook will pick it up and generate this dep for us.
// @ts-expect-error We know we shouldn't set a random attribute...
chunk.needsWrmRequire = true;
// Add the WRM async loader in to webpack's loader function.
return (0, runtime_load_shim_1.default)(this.options.pluginKey, this.options.watch, compiler.options.output.publicPath);
});
});
}
shouldOverwritePublicPath() {
if (this.options.watch) {
return false;
}
if (this.options.noWRM) {
return false;
}
return true;
}
shouldEnableAsyncLoadingWithWRM() {
return !this.options.noWRM;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
apply(compiler) {
// ensure settings make sense
this.checkConfig(compiler);
// hook up external dependencies
this.hookUpProvidedDependencies(compiler);
// allow `wr-dependency/wr-resource` require calls.
this.injectWRMSpecificRequestTypes(compiler);
if (this.shouldOverwritePublicPath()) {
this.overwritePublicPath(compiler);
}
if (this.shouldEnableAsyncLoadingWithWRM()) {
this.enableAsyncLoadingWithWRM(compiler);
}
const outputPath = compiler.options.output.path;
const isProductionMode = (0, WebpackHelpers_1.isRunningInProductionMode)(compiler);
const assetsUUID = this.getAssetsUUID(isProductionMode);
const assetNames = new Map();
// Generate a 1:1 mapping from original filenames to compiled filenames
compiler.hooks.compilation.tap('wrm plugin setup phase', (compilation) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const moduleLoader = require('webpack/lib/NormalModule').getCompilationHooks(compilation).loader;
moduleLoader.tap('wrm plugin - normal module', (loaderContext, module) => {
const { emitFile } = loaderContext;
loaderContext.emitFile = (name, content, sourceMap) => {
const originalName = module.userRequest;
assetNames.set(originalName, name);
return emitFile.call(module, name, content, sourceMap);
};
});
});
const appResourcesFactory = new AppResourcesFactory_1.default({
assetsUUID,
assetNames,
options: this.options,
});
/**
* Given a completed compilation, determine where each file generated
* by webpack should be referenced in a web-resource.
*
* @param compilation the finalised compilation for the build
* @returns a list of {@see ChunkResourceDescriptor} objects which describe a web-resource bundle
*/
const getWebResourceDescriptors = (compilation) => {
const appResourceGenerator = appResourcesFactory.build(compiler, compilation);
return appResourceGenerator.getResourceDescriptors();
};
/**
* Given a list of web-resource descriptors, write their definitions
* to XML, ready for the WRM to pick them up at product runtime.
*
* @param descriptors the list of web-resources to write definitions for
*/
const generateWebResourceXmlReport = (descriptors) => {
const webResources = descriptors.map((descriptor) => (0, web_resource_generator_1.renderWebResource)(descriptor, descriptors, this.options));
const xmlDescriptorsFilepath = this.options.xmlDescriptors;
const xmlDescriptors = pretty_data_1.pd.xml(`<bundles>${webResources.join('')}</bundles>`);
(0, file_system_1.writeFileSync)(xmlDescriptorsFilepath, xmlDescriptors);
};
/**
* Given a list of web-resource descriptors, identify JS assets and list them in
* association report.
*/
const generateAssociationReport = (buildOutputPath, descriptors) => {
const files = [...new Set(descriptors.map((descriptor) => descriptor.resources).flat()).values()];
const output = {
packageName: this.options.packageName,
outputDirectoryFiles: files,
};
const jsonOutput = JSON.stringify(output, null, 2);
const fullOutputPath = path_1.default.join(buildOutputPath, ASSOCIATIONS_PATH_IN_BUNDLE, `${this.options.pluginKey}-webpack.intermediary.json`);
(0, file_system_1.writeFileSync)(fullOutputPath, jsonOutput);
};
/**
* Given a list of web-resource descriptors, for each that
* references javascript files, overwrite them with new files that
* will request content from the webpack-dev-server after the WRM
* loads them at product runtime.
*
* @param descriptors the list of web-resources to scan and find
* javascript files within
*/
const generateHotModuleRedirectFiles = (descriptors) => {
const redirectDescriptors = (0, flatMap_1.default)(descriptors, (c) => c.resources)
.filter((res) => path_1.default.extname(res) === '.js')
.map((r) => ({ fileName: r, writePath: path_1.default.join(outputPath, r) }));
const overwriteFiles = () => {
const generateAssetCall = (fileName) => {
// TODO: should we just pretend `publicPath` is a string here?
const pathName = (0, url_join_1.default)(compiler.options.output.publicPath, fileName);
const appendScript = `
var script = document.createElement('script');
script.src = '${pathName}';
script.async = false;
script.crossOrigin = 'anonymous';
document.head.appendChild(script);`.trim();
if (this.options.useDocumentWriteInWatchMode) {
return `
!function(){
if (document.readyState === "loading" && 'initiallyRendered' in document.currentScript.dataset) {
document.write('<script src="${pathName}"></script>')
} else {
${appendScript}
}
}();
`;
}
return `!function() { ${appendScript} }();`;
};
for (const { fileName, writePath } of redirectDescriptors) {
(0, file_system_1.writeFileSync)(writePath, generateAssetCall(fileName));
}
};
compiler.hooks.afterDone.tap('wrm plugin - add watch mode modules', overwriteFiles);
};
/**
* After the compilation is complete, we analyse the result to produce our descriptors,
* which we then use to generate various metadata files in the build output.
*/
(0, WebpackRuntimeHelpers_1.hookIntoCompileDoneToGenerateReports)('wrm plugin - generate descriptors', compiler, (compilation, cb) => {
const descriptors = getWebResourceDescriptors(compilation);
// write the xml for web-resource module descriptors
generateWebResourceXmlReport(descriptors);
// generate association report in case packageName is provided
if (this.options.packageName) {
generateAssociationReport(compiler.outputPath, descriptors);
}
// write javascript files to enable hot-reloading at dev time
if (this.options.watch && this.options.watchPrepare) {
generateHotModuleRedirectFiles(descriptors);
}
cb();
});
// Enable manifest output if provided
if (this.options.wrmManifestPath) {
const filename = path_1.default.isAbsolute(this.options.wrmManifestPath)
? this.options.wrmManifestPath
: path_1.default.resolve(path_1.default.join(outputPath, this.options.wrmManifestPath));
new WrmManifestPlugin_1.default(appResourcesFactory, filename, this.options.pluginKey).apply(compiler);
}
}
}
module.exports = WrmPlugin;
//# sourceMappingURL=WrmPlugin.js.map
;