├── .prettierrc ├── .gitignore ├── images └── atriom-logo.png ├── package.json ├── README.md ├── helpers └── index.js ├── convertToGraph.js └── AtriomPlugin.js /.prettierrc: -------------------------------------------------------------------------------- 1 | { "singleQuote": true } 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | package-lock.json -------------------------------------------------------------------------------- /images/atriom-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/atriom-plugin/HEAD/images/atriom-logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atriom-plugin", 3 | "version": "1.1.0", 4 | "main": "AtriomPlugin.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "test": "jest --watch" 8 | }, 9 | "dependencies": { 10 | "@module-federation/automatic-vendor-federation": "^1.0.1", 11 | "flatted": "^2.0.2" 12 | }, 13 | "peerDependencies": { 14 | "webpack": "^5.31.0", 15 | "webpack-sources": "^1.4.3" 16 | }, 17 | "devDependencies": { 18 | "jest": "26.0.1", 19 | "webpack": "^5.31.0", 20 | "webpack-sources": "^1.4.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Atriom logo](/images/atriom-logo.png) 2 | 3 | ## Getting Started 4 | 5 | ### Introduction 6 | 7 | The Atriom webpack plugin extracts data from the webpack build process and writes a `.dat` file to a user-specified output path. This plugin is intended for use with applications built with the Webpack 5 `ModuleFederationPlugin`. 8 | The generated `.dat` file will contain the following information: 9 | 10 | - name and id of the application 11 | - application dependencies 12 | - development dependencies 13 | - optional dependencies 14 | - application remote 15 | - overrides 16 | - consumed modules 17 | - exposed modules 18 | 19 | ### Prerequisites 20 | 21 | - Webpack version 5.31.0 or higher 22 | 23 | ### Installation 24 | 25 | `npm i --save-dev atriom-plugin` 26 | 27 | ## Usage 28 | 29 | Add Atriom plugin and configuration to your `webpack.config.js` file. 30 | 31 | ### Example 32 | 33 | ``` 34 | const AtriomPlugin = require('atriom-plugin'); 35 | const path = require('path'); 36 | ``` 37 | 38 | ``` 39 | plugins: [ 40 | ... 41 | new AtriomPlugin({ 42 | filename: "ATRIOM", 43 | outputPath: path.join(__dirname, "../"), 44 | }), 45 | ... 46 | ] 47 | ``` 48 | | Key | Description | 49 | | ------------ | ------------------------------------------------------- | 50 | | `filename` | Name of the output file\* | 51 | | `outputPath` | File path for the .dat file generated by the plugin\*\* | 52 | 53 | \*The `fileName` in each federated application's `webpack.config.js` file must be identical. 54 | 55 | \*\*The `outputPath` for each federated application must point to the same location. 56 | \ 57 | \ 58 | Once the plugin has been added and configured correctly, simply run your build script for each federated application: 59 | 60 | `webpack --mode production` 61 | \ 62 | \ 63 | The Atriom plugin will generate a `.dat` file at the specified location, which is ready to be used with the [Atriom Dashboard](http://atriomdashboard.io). 64 | -------------------------------------------------------------------------------- /helpers/index.js: -------------------------------------------------------------------------------- 1 | function validateParams({ 2 | federationRemoteEntry, 3 | topLevelPackage, 4 | metadata, 5 | modules, 6 | }) { 7 | function objHasKeys(nestedObj, pathArr) { 8 | return pathArr.reduce( 9 | (obj, key) => (obj && obj[key] !== 'undefined' ? obj[key] : undefined), 10 | nestedObj 11 | ); 12 | } 13 | 14 | const hasLoc = federationRemoteEntry 15 | ? objHasKeys(federationRemoteEntry, ['origins', '0', 'loc']) 16 | : federationRemoteEntry; 17 | 18 | const hasDependencies = objHasKeys(topLevelPackage, ['dependencies']); 19 | const hasDevDependencies = objHasKeys(topLevelPackage, ['devDependencies']); 20 | const hasOptionalDependencies = objHasKeys(topLevelPackage, [ 21 | 'optionalDependencies', 22 | ]); 23 | if (federationRemoteEntry) { 24 | if ( 25 | typeof hasLoc === 'undefined' || 26 | federationRemoteEntry.origins[0].loc === '' 27 | ) { 28 | throw new Error( 29 | 'federationRemoteEntry.origins[0].loc must be defined and have a value' 30 | ); 31 | } 32 | } 33 | if ((modules && !modules.length) || typeof modules === 'undefined') { 34 | throw new Error('Modules must be defined and have length'); 35 | } 36 | 37 | if (typeof hasDependencies === 'undefined') { 38 | throw new Error('topLevelPackage.dependencies must be defined'); 39 | } 40 | 41 | if (typeof hasDevDependencies === 'undefined') { 42 | throw new Error('topLevelPackage.devDependencies must be defined'); 43 | } 44 | 45 | if (typeof hasOptionalDependencies === 'undefined') { 46 | throw new Error('topLevelPackage.optionalDependencies must be defined'); 47 | } 48 | 49 | for (let module of modules) { 50 | if (typeof module.identifier === 'undefined') { 51 | throw new Error('module.identifier must be defined'); 52 | } 53 | if (typeof module.reasons === 'undefined') { 54 | throw new Error('module.reasons must be defined'); 55 | } 56 | if (typeof module.issuerName === 'undefined') { 57 | throw new Error('module.issuerName must be defined'); 58 | } 59 | } 60 | } 61 | 62 | module.exports = { validateParams }; 63 | -------------------------------------------------------------------------------- /convertToGraph.js: -------------------------------------------------------------------------------- 1 | const { validateParams } = require('./helpers'); 2 | const convertToGraph = ({ 3 | name, 4 | federationRemoteEntry, 5 | modules, 6 | topLevelPackage, 7 | metadata, 8 | }) => { 9 | validateParams({ federationRemoteEntry, modules, topLevelPackage, metadata }); 10 | 11 | const app = name; 12 | const overrides = {}; 13 | const consumes = []; 14 | const consumesByName = {}; 15 | const modulesObj = {}; 16 | 17 | modules.forEach(({ identifier, reasons }) => { 18 | const data = identifier.split(' '); 19 | if (data[0] === 'remote') { 20 | if (data.length === 4) { 21 | const name = data[3].replace('./', ''); 22 | const consume = { 23 | consumingApplicationID: app, 24 | applicationID: data[2].replace('webpack/container/reference/', ''), 25 | name, 26 | usedIn: new Set(), 27 | }; 28 | consumes.push(consume); 29 | consumesByName[`${consume.applicationID}/${name}`] = consume; 30 | } 31 | if (reasons) { 32 | reasons.forEach(({ userRequest, resolvedModule, type }) => { 33 | if (consumesByName[userRequest]) { 34 | consumesByName[userRequest].usedIn.add( 35 | resolvedModule.replace('./', '') 36 | ); 37 | } 38 | }); 39 | } 40 | } else if (data[0] === 'container' && data[1] === 'entry') { 41 | JSON.parse(data[3]).forEach(([prefixedName, file]) => { 42 | const name = prefixedName.replace('./', ''); 43 | modulesObj[file.import[0]] = { 44 | id: `${app}:${name}`, 45 | name, 46 | applicationID: app, 47 | requires: new Set(), 48 | file: file.import[0], 49 | }; 50 | }); 51 | } 52 | }); 53 | 54 | const convertDeps = (deps = {}) => 55 | Object.entries(deps).map(([version, name]) => ({ 56 | name, 57 | version: version.replace(`${name}-`, ''), 58 | })); 59 | const convertedDeps = { 60 | dependencies: convertDeps(topLevelPackage.dependencies), 61 | devDependencies: convertDeps(topLevelPackage.devDependencies), 62 | optionalDependencies: convertDeps(topLevelPackage.optionalDependencies), 63 | }; 64 | 65 | modules.forEach(({ identifier, issuerName, reasons }) => { 66 | const data = identifier.split('|'); 67 | 68 | if (data[0] === 'consume-shared-module') { 69 | if (issuerName) { 70 | // This is a hack 71 | const issuerNameMinusExtension = issuerName.replace('.js', ''); 72 | if (modulesObj[issuerNameMinusExtension]) { 73 | modulesObj[issuerNameMinusExtension].requires.add(data[2]); 74 | } 75 | } 76 | if (reasons) { 77 | reasons.forEach(({ module }) => { 78 | const moduleMinusExtension = module.replace('.js', ''); 79 | if (modulesObj[moduleMinusExtension]) { 80 | modulesObj[moduleMinusExtension].requires.add(data[2]); 81 | } 82 | }); 83 | } 84 | let version = ''; 85 | [ 86 | convertedDeps.dependencies, 87 | convertedDeps.devDependencies, 88 | convertedDeps.optionalDependencies, 89 | ].forEach((deps) => { 90 | const dep = deps.find(({ name }) => name === data[2]); 91 | if (dep) { 92 | version = dep.version; 93 | } 94 | }); 95 | 96 | overrides[data[2]] = { 97 | id: data[2], 98 | name: data[2], 99 | version, 100 | location: data[2], 101 | applicationID: app, 102 | }; 103 | } 104 | }); 105 | 106 | const sourceUrl = metadata && metadata.source ? metadata.source.url : ''; 107 | const remote = metadata && metadata.remote ? metadata.remote : ''; 108 | 109 | const out = { 110 | ...convertedDeps, 111 | id: app, 112 | name: app, 113 | remote, 114 | overrides: Object.values(overrides), 115 | consumes: consumes.map((con) => ({ 116 | ...con, 117 | usedIn: Array.from(con.usedIn.values()).map((file) => ({ 118 | file, 119 | url: `${sourceUrl}/${file}`, 120 | })), 121 | })), 122 | modules: Object.values(modulesObj).map((mod) => ({ 123 | ...mod, 124 | requires: Array.from(mod.requires.values()), 125 | })), 126 | }; 127 | 128 | return out; 129 | }; 130 | 131 | module.exports = convertToGraph; 132 | -------------------------------------------------------------------------------- /AtriomPlugin.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const AutomaticVendorFederation = require('@module-federation/automatic-vendor-federation'); 3 | const convertToGraph = require('./convertToGraph'); 4 | 5 | /** @typedef {import('webpack/lib/Compilation')} Compilation */ 6 | /** @typedef {import('webpack/lib/Compiler')} Compiler */ 7 | 8 | /** 9 | * @typedef AtriomPluginOptions 10 | * @property {string} filename 11 | * @property {function} reportFunction 12 | */ 13 | 14 | const PLUGIN_NAME = 'AtriomPlugin'; 15 | 16 | class AtriomPlugin { 17 | /** 18 | * 19 | * @param {AtriomPluginOptions} options 20 | */ 21 | constructor(options) { 22 | this._options = options; 23 | this._dashData = null; 24 | } 25 | 26 | /** 27 | * @param {Compiler} compiler 28 | */ 29 | apply(compiler) { 30 | const FederationPlugin = compiler.options.plugins.find((plugin) => { 31 | return plugin.constructor.name === 'ModuleFederationPlugin'; 32 | }); 33 | let FederationPluginOptions; 34 | if (FederationPlugin) { 35 | FederationPluginOptions = FederationPlugin._options; 36 | } 37 | 38 | compiler.hooks.afterDone.tap(PLUGIN_NAME, (liveStats) => { 39 | if (!this._options.outputPath) { 40 | console.warn('ATRIOM WARNING: No output path provided in options.'); 41 | process.exit(1); 42 | } 43 | const stats = liveStats.toJson(); 44 | 45 | // find relevant module objects 46 | const modules = stats.modules.filter((module) => { 47 | const array = [ 48 | module.name.includes('container entry'), 49 | // modules brought in from other apps 50 | module.name.includes('remote '), 51 | // shared dependencies (react, redux, etc.) 52 | module.name.includes('shared module '), 53 | module.name.includes('provide module '), 54 | ]; 55 | return array.some((item) => item); 56 | }); 57 | const directReasons = new Set(); 58 | Array.from(modules).forEach((module) => { 59 | if (module.reasons) { 60 | module.reasons.forEach((reason) => { 61 | if (reason.userRequest) { 62 | try { 63 | // grab user required package.json 64 | const subsetPackage = require(reason.userRequest + 65 | '/package.json'); 66 | directReasons.add(subsetPackage); 67 | } catch (e) {} 68 | } 69 | }); 70 | } 71 | }); 72 | // get RemoteEntryChunk 73 | // find chunk associated with current app - find first chunk in stats.chunks with a name that matches the current app 74 | const RemoteEntryChunk = stats.chunks.find((chunk) => { 75 | const specificChunk = chunk.names.find((name) => { 76 | return name === FederationPluginOptions.name; 77 | }); 78 | return specificChunk; 79 | }); 80 | // use liveStats.compilation.namedChunks (JS Map Object) 81 | // get chunk that is associated with the current application 82 | // getting this by using the chunk associated with FederationPluginOptions.name provided in webpack config 83 | const namedChunkRefs = liveStats.compilation.namedChunks.get( 84 | FederationPluginOptions.name 85 | ); 86 | 87 | // AllReferencedChunksByRemote is a Set (or array if namedChunkRefs is falsey) 88 | const AllReferencedChunksByRemote = namedChunkRefs 89 | ? namedChunkRefs.getAllReferencedChunks() 90 | : []; 91 | 92 | const validChunkArray = []; 93 | AllReferencedChunksByRemote.forEach((chunk) => { 94 | if (chunk.id !== FederationPluginOptions.name) { 95 | // will chunk.id ever equal FederationPluginOptions.name?? - FederationPluginOptions.name refers to the name of the application 96 | validChunkArray.push(chunk); 97 | } 98 | }); 99 | // validChunkArray is now an array of chunk objects (in this case, identical to the AllReferencedChunksByRemote Set) 100 | 101 | function mapToObjectRec(m) { 102 | let lo = {}; 103 | for (let [k, v] of Object.entries(m)) { 104 | if (v instanceof Map) { 105 | lo[k] = mapToObjectRec(v); 106 | } else if (v instanceof Set) { 107 | lo[k] = mapToObjectRec(Array.from(v)); 108 | } else { 109 | lo[k] = v; 110 | } 111 | } 112 | return lo; 113 | } 114 | 115 | const chunkDependencies = validChunkArray.reduce((acc, chunk) => { 116 | const subset = chunk.getAllReferencedChunks(); 117 | const stringifiableChunk = Array.from(subset).map((sub) => { 118 | const cleanSet = Object.getOwnPropertyNames(sub).reduce( 119 | (acc, key) => { 120 | if (key === '_groups') return acc; 121 | return Object.assign(acc, { [key]: sub[key] }); 122 | }, 123 | {} 124 | ); 125 | return mapToObjectRec(cleanSet); 126 | }); 127 | return Object.assign(acc, { 128 | [chunk.id]: stringifiableChunk, 129 | }); 130 | }, {}); 131 | let packageJson, 132 | vendorFederation = {}; 133 | try { 134 | packageJson = require(liveStats.compilation.options.context + 135 | '/package.json'); 136 | } catch (e) {} 137 | if (packageJson) { 138 | vendorFederation.dependencies = AutomaticVendorFederation({ 139 | exclude: [], 140 | ignoreVersion: false, 141 | packageJson, 142 | subPackages: Array.from(directReasons), 143 | shareFrom: ['dependencies'], 144 | ignorePatchVersion: true, 145 | }); 146 | vendorFederation.devDependencies = AutomaticVendorFederation({ 147 | exclude: [], 148 | ignoreVersion: false, 149 | packageJson, 150 | subPackages: Array.from(directReasons), 151 | shareFrom: ['devDependencies'], 152 | ignorePatchVersion: true, 153 | }); 154 | vendorFederation.optionalDependencies = AutomaticVendorFederation({ 155 | exclude: [], 156 | ignoreVersion: false, 157 | packageJson, 158 | subPackages: Array.from(directReasons), 159 | shareFrom: ['optionalDependencies'], 160 | ignorePatchVersion: true, 161 | }); 162 | } 163 | 164 | const rawData = { 165 | name: FederationPluginOptions.name, 166 | metadata: this._options.metadata || {}, 167 | topLevelPackage: vendorFederation || {}, 168 | publicPath: stats.publicPath, 169 | federationRemoteEntry: RemoteEntryChunk, 170 | buildHash: stats.hash, 171 | modules, 172 | chunkDependencies, 173 | }; 174 | 175 | let graphData = null; 176 | try { 177 | graphData = convertToGraph(rawData); 178 | } catch (err) { 179 | console.warn('Error during dashboard data processing'); 180 | console.warn(err); 181 | } 182 | 183 | if (graphData) { 184 | const dashData = (this._dashData = JSON.stringify(graphData)); 185 | 186 | // Write to user-specified path 187 | // Filename will be user-specified or 'ATRIOM' 188 | const filePathAtriom = `${this._options.outputPath}/${ 189 | this._options.filename || 'ATRIOM' 190 | }.dat`; 191 | console.log('ATRIOM: Writing to...', filePathAtriom); 192 | fs.appendFile( 193 | filePathAtriom, 194 | dashData + ',', 195 | { encoding: 'utf-8' }, 196 | () => {} 197 | ); 198 | } 199 | }); 200 | } 201 | } 202 | 203 | module.exports = AtriomPlugin; 204 | --------------------------------------------------------------------------------