diff --git a/consts.js b/consts.js new file mode 100644 index 0000000..1db7962 --- /dev/null +++ b/consts.js @@ -0,0 +1,29 @@ +// TODO: should these come from https://github.com/folio-org/stripes-core/blob/1d5d4f00a3756702e828856d4ef9349ceb9f1c08/package.json#L116-L129 +// Anythign that we want *the platform to provide to modules should be here. +// If an item is not in this list, modules will each load their own version of it. +// This can be problematic for React Context if mutliple copies of the same context are loaded. + +const singletons = { + '@folio/stripes': '^9.3.0', + '@folio/stripes-components': '^13.1.0', + '@folio/stripes-connect': '^10.0.1', + '@folio/stripes-core': '^11.1.0', + '@folio/stripes-shared-context': '^1.0.0', + "moment": "^2.29.0", + 'react': '~18.3', + 'react-dom': '~18.3', + 'react-intl': '^7.1.14', + 'react-query': '^3.39.3', + 'react-redux': '^8.1', + 'react-router': '^5.2.0', + 'react-router-dom': '^5.2.0', + 'redux-observable': '^1.2.0', + 'rxjs': '^6.6.3' +}; + +const defaultRegistryUrl = 'http://localhost:3001/registry'; + +module.exports = { + defaultRegistryUrl, + singletons, +}; diff --git a/package.json b/package.json index c79f806..945ca4b 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,13 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", "@svgr/webpack": "^8.1.0", "add-asset-html-webpack-plugin": "^6.0.0", + "axios": "^1.3.6", "autoprefixer": "^10.4.13", "babel-loader": "^9.1.3", "buffer": "^6.0.3", "connect-history-api-fallback": "^1.3.0", "core-js": "^3.6.1", + "cors": "^2.8.5", "css-loader": "^6.4.0", "csv-loader": "^3.0.3", "debug": "^4.0.1", @@ -52,6 +54,7 @@ "lodash": "^4.17.21", "mini-css-extract-plugin": "^2.7.6", "node-object-hash": "^1.2.0", + "portfinder": "^1.0.32", "postcss": "^8.4.2", "postcss-custom-media": "^9.0.1", "postcss-import": "^15.0.1", @@ -70,6 +73,7 @@ "util-ex": "^0.3.15", "validate-npm-package-name": "^6.0.2", "webpack-dev-middleware": "^5.2.1", + "webpack-dev-server": "^4.13.1", "webpack-hot-middleware": "^2.25.1", "webpack-remove-empty-scripts": "^1.0.1", "webpack-virtual-modules": "^0.4.3" @@ -88,4 +92,4 @@ "react-dom": "^18.2.0", "webpack": "^5.58.1" } -} +} \ No newline at end of file diff --git a/webpack.config.base.js b/webpack.config.base.js index 0be1037..14d76f3 100644 --- a/webpack.config.base.js +++ b/webpack.config.base.js @@ -5,11 +5,16 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const RemoveEmptyScriptsPlugin = require('webpack-remove-empty-scripts'); +const { ModuleFederationPlugin } = require('webpack').container; -const { generateStripesAlias } = require('./webpack/module-paths'); +const { generateStripesAlias, } = require('./webpack/module-paths'); +const { processShared } = require('./webpack/utils'); const typescriptLoaderRule = require('./webpack/typescript-loader-rule'); const { isProduction } = require('./webpack/utils'); const { getTranspiledCssPaths } = require('./webpack/module-paths'); +const { singletons } = require('./consts'); + +const shared = processShared(singletons, { singleton: true, eager: true }); // React doesn't like being included multiple times as can happen when using // yarn link. Here we find a more specific path to it by first looking in @@ -65,6 +70,7 @@ const baseConfig = { }), new webpack.EnvironmentPlugin(['NODE_ENV']), new RemoveEmptyScriptsPlugin(), + new ModuleFederationPlugin({ name: 'host', shared }), ], module: { rules: [ @@ -131,7 +137,6 @@ const baseConfig = { }, }; - const buildConfig = (modulePaths) => { const transpiledCssPaths = getTranspiledCssPaths(modulePaths); const cssDistPathRegex = /dist[\/\\]style\.css/; diff --git a/webpack.config.cli.dev.js b/webpack.config.cli.dev.js index 19dad06..4c42e83 100644 --- a/webpack.config.cli.dev.js +++ b/webpack.config.cli.dev.js @@ -9,7 +9,7 @@ const esbuildLoaderRule = require('./webpack/esbuild-loader-rule'); const utils = require('./webpack/utils'); const buildBaseConfig = require('./webpack.config.base'); const cli = require('./webpack.config.cli'); - +const StripesFederationPlugin = require('./webpack/stripes-federation-plugin'); const useBrowserMocha = () => { return tryResolve('mocha/mocha-es2018.js') ? 'mocha/mocha-es2018.js' : 'mocha'; @@ -56,7 +56,8 @@ const buildConfig = (stripesConfig) => { if (utils.isDevelopment) { devConfig.plugins = devConfig.plugins.concat([ new webpack.HotModuleReplacementPlugin(), - new ReactRefreshWebpackPlugin() + new ReactRefreshWebpackPlugin(), + new StripesFederationPlugin(stripesConfig) ]); } diff --git a/webpack.config.federate.remote.js b/webpack.config.federate.remote.js new file mode 100644 index 0000000..db97085 --- /dev/null +++ b/webpack.config.federate.remote.js @@ -0,0 +1,145 @@ +const path = require('path'); +const webpack = require('webpack'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const StripesTranslationsPlugin = require('./webpack/stripes-translations-plugin'); +const { container } = webpack; +const { processExternals, processShared } = require('./webpack/utils'); +const { getStripesModulesPaths } = require('./webpack/module-paths'); +const esbuildLoaderRule = require('./webpack/esbuild-loader-rule'); +const { singletons } = require('./consts'); + +const buildConfig = (metadata) => { + const { host, port, name, displayName, main } = metadata; + + // using main from metadata since the location of main could vary between modules. + let mainEntry = path.join(process.cwd(), main || 'index.js'); + const stripesModulePaths = getStripesModulesPaths(); + const translationsPath = path.join(process.cwd(), 'translations', displayName.split('.').shift()); + const iconsPath = path.join(process.cwd(), 'icons'); + + // yeah, yeah, soundsPath vs sound. sorry. `sound` is a legacy name. + // other paths are plural and I'm sticking with that convention. + const soundsPath = path.join(process.cwd(), 'sound'); + + const shared = processShared(singletons, { singleton: true }); + + const config = { + name, + devtool: 'inline-source-map', + mode: 'development', + entry: mainEntry, + output: { + publicPath: `${host}:${port}/`, + }, + devServer: { + port: port, + open: false, + headers: { + 'Access-Control-Allow-Origin': '*', + }, + static: [ + { + directory: translationsPath, + publicPath: '/translations' + }, + { + directory: iconsPath, + publicPath: '/icons' + }, + { + directory: soundsPath, + publicPath: '/sounds' + }, + ] + }, + module: { + rules: [ + esbuildLoaderRule(stripesModulePaths), + { + test: /\.(woff2?)$/, + type: 'asset/resource', + generator: { + filename: './fonts/[name].[contenthash].[ext]', + }, + }, + { + test: /\.css$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + { + loader: 'css-loader', + options: { + modules: { + localIdentName: '[local]---[hash:base64:5]', + }, + sourceMap: true, + importLoaders: 1, + }, + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + config: path.resolve(__dirname, 'postcss.config.js'), + }, + sourceMap: true, + }, + }, + ], + }, + { + test: /\.(jpg|jpeg|gif|png|ico)$/, + type: 'asset/resource', + generator: { + filename: './img/[name].[contenthash].[ext]', + }, + }, + // { + // test: /\.svg$/, + // use: [{ + // loader: 'url-loader', + // options: { + // esModule: false, + // }, + // }] + // }, + { + test: /\.svg$/, + type: 'asset/inline', + resourceQuery: { not: /icon/ } // exclude built-in icons from stripes-components which are loaded as react components. + }, + { + test: /\.svg$/, + resourceQuery: /icon/, // stcom icons use this query on the resource. + use: ['@svgr/webpack'] + }, + { + test: /\.js.map$/, + enforce: "pre", + use: ['source-map-loader'], + } + ] + }, + // TODO: remove this after stripes-config is gone. + externals: processExternals({ 'stripes-config': true }), + plugins: [ + new StripesTranslationsPlugin({ federate: true }), + new MiniCssExtractPlugin({ filename: 'style.css', ignoreOrder: false }), + new container.ModuleFederationPlugin({ + library: { type: 'var', name }, + name, + filename: 'remoteEntry.js', + exposes: { + './MainEntry': mainEntry, + }, + shared + }), + ] + }; + + return config; +} + +module.exports = buildConfig; diff --git a/webpack/federate.js b/webpack/federate.js new file mode 100644 index 0000000..7380f8f --- /dev/null +++ b/webpack/federate.js @@ -0,0 +1,67 @@ +const path = require('path'); +const webpack = require('webpack'); +const WebpackDevServer = require('webpack-dev-server'); +const axios = require('axios'); +const { snakeCase } = require('lodash'); +const portfinder = require('portfinder'); + +const buildConfig = require('../webpack.config.federate.remote'); +const { tryResolve } = require('./module-paths'); +const logger = require('./logger')(); + +// Remotes will be serve starting from port 3002 +portfinder.setBasePort(3002); + +module.exports = async function federate(options = {}) { + logger.log('starting federation...'); + + const packageJsonPath = tryResolve(path.join(process.cwd(), 'package.json')); + + if (!packageJsonPath) { + console.error('package.json not found'); + process.exit(); + } + + const port = options.port ?? await portfinder.getPortPromise(); + const host = `http://localhost`; + const url = `${host}:${port}/remoteEntry.js`; + + const { name: packageName, version, description, stripes, main } = require(packageJsonPath); + const { permissionSets: _, ...stripesRest } = stripes; + const name = snakeCase(packageName); + const metadata = { + module: packageName, + version, + description, + host, + port, + url, + name, + main, + ...stripesRest, + }; + + const config = buildConfig(metadata); + + // TODO: allow for configuring registryUrl via env var or stripes config + const registryUrl = 'http://localhost:3001/registry'; + + // update registry + axios.post(registryUrl, metadata).catch(error => { + console.error(`Registry not found. Please check ${registryUrl}`); + process.exit(); + }); + + const compiler = webpack(config); + const server = new WebpackDevServer(config.devServer, compiler); + console.log(`Starting remote server on port ${port}`); + server.start(); + + compiler.hooks.shutdown.tapPromise('AsyncShutdownHook', async (stats) => { + try { + await axios.delete(registryUrl, { data: metadata }); + } catch (error) { + console.error(`registry not found. Please check ${registryUrl}`); + } + }); +}; diff --git a/webpack/module-paths.js b/webpack/module-paths.js index 00ea92b..de1ce03 100644 --- a/webpack/module-paths.js +++ b/webpack/module-paths.js @@ -63,7 +63,7 @@ function locateStripesModule(context, moduleName, alias, ...segments) { } // When available, try for the alias first - if (alias[moduleName]) { + if (alias && alias[moduleName]) { tryPaths.unshift({ request: path.join(alias[moduleName], ...segments), }); @@ -264,4 +264,5 @@ module.exports = { getNonTranspiledModules, getTranspiledModules, getTranspiledCssPaths, + locatePackageJsonPath, }; diff --git a/webpack/registryServer.js b/webpack/registryServer.js new file mode 100644 index 0000000..b4e3fb4 --- /dev/null +++ b/webpack/registryServer.js @@ -0,0 +1,44 @@ +const express = require('express'); +const cors = require('cors'); + +// Registry data +const registry = { remotes: {} }; + +const registryServer = { + start: () => { + const app = express(); + + app.use(express.json()); + app.use(cors()); + + // add/update remote to registry + app.post('/registry', (req, res) => { + const metadata = req.body; + const { name } = metadata; + + registry.remotes[name] = metadata; + res.status(200).send(`Remote ${name} metadata updated`); + }); + + // return entire registry for machines + app.get('/registry', (_, res) => res.json(registry)); + + // return entire registry for humans + app.get('/code', (_, res) => res.send(`
${JSON.stringify(registry, null, 2)}
`)); + + app.delete('/registry', (req, res) => { + const metadata = req.body; + const { name } = metadata; + + delete registry.remotes[name]; + + res.status(200).send(`Remote ${name} removed`); + }); + + app.listen(3001, () => { + console.log('Starting registry server at http://localhost:3001'); + }); + } +}; + +module.exports = registryServer; diff --git a/webpack/serve.js b/webpack/serve.js index ef64a1c..2d64af8 100644 --- a/webpack/serve.js +++ b/webpack/serve.js @@ -9,6 +9,7 @@ const applyWebpackOverrides = require('./apply-webpack-overrides'); const logger = require('./logger')(); const buildConfig = require('../webpack.config.cli.dev'); const sharedStylesConfig = require('../webpack.config.cli.shared.styles'); +const registryServer = require('./registryServer'); const cwd = path.resolve(); const platformModulePath = path.join(cwd, 'node_modules'); @@ -24,6 +25,15 @@ module.exports = function serve(stripesConfig, options) { logger.log('starting serve...'); const app = express(); + + // stripes module registry + try { + registryServer.start(); + } + catch (e) { + console.error(e) + } + let config = buildConfig(stripesConfig); config = sharedStylesConfig(config, {}); diff --git a/webpack/stripes-config-plugin.js b/webpack/stripes-config-plugin.js index bb2c48e..d5579ed 100644 --- a/webpack/stripes-config-plugin.js +++ b/webpack/stripes-config-plugin.js @@ -13,6 +13,7 @@ const { SyncHook } = require('tapable'); const stripesModuleParser = require('./stripes-module-parser'); const StripesBuildError = require('./stripes-build-error'); const stripesSerialize = require('./stripes-serialize'); +const { defaultRegistryUrl } = require('../consts'); const logger = require('./logger')('stripesConfigPlugin'); const stripesConfigPluginHooksMap = new WeakMap(); @@ -47,7 +48,14 @@ module.exports = class StripesConfigPlugin { const enabledModules = this.options.modules; logger.log('enabled modules:', enabledModules); const { config, metadata, icons, stripesDeps, warnings, lazyImports } = stripesModuleParser.parseAllModules(enabledModules, compiler.context, compiler.options.resolve.alias, this.lazy); - this.mergedConfig = Object.assign({}, this.options, { modules: config }); + const modulesInitialState = { + app: [], + handler: [], + plugin: [], + settings: [], + }; + this.mergedOkapi = Object.assign({ registryUrl: defaultRegistryUrl }, this.options.okapi); + this.mergedConfig = Object.assign({ modules: modulesInitialState }, this.options, { modules: config, okapi: this.mergedOkapi }); this.metadata = metadata; this.icons = icons; this.warnings = warnings; @@ -58,18 +66,16 @@ module.exports = class StripesConfigPlugin { StripesConfigPlugin.getPluginHooks(compiler).beforeWrite.tap( { name: 'StripesConfigPlugin', context: true }, - context => Object.assign(context, { config, metadata, icons, stripesDeps, warnings })); + context => Object.assign(context, { config })); // Wait until after other plugins to generate virtual stripes-config compiler.hooks.afterPlugins.tap('StripesConfigPlugin', (theCompiler) => this.afterPlugins(theCompiler)); - compiler.hooks.emit.tapAsync('StripesConfigPlugin', (compilation, callback) => this.processWarnings(compilation, callback)); } afterPlugins(compiler) { // Data provided by other stripes plugins via hooks const pluginData = { branding: {}, - errorLogging: {}, translations: {}, }; @@ -79,22 +85,15 @@ module.exports = class StripesConfigPlugin { const stripesVirtualModule = ` ${Array.from(this.lazyImports).join('\n')} const { okapi, config, modules } = ${serialize(this.mergedConfig, { space: 2 })}; - const branding = ${stripesSerialize.serializeWithRequire(pluginData.branding)}; const errorLogging = ${stripesSerialize.serializeWithRequire(pluginData.errorLogging)}; + const branding = ${stripesSerialize.serializeWithRequire(pluginData.branding)}; const translations = ${serialize(pluginData.translations, { space: 2 })}; const metadata = ${stripesSerialize.serializeWithRequire(this.metadata)}; const icons = ${stripesSerialize.serializeWithRequire(this.icons)}; - export { okapi, config, modules, branding, errorLogging, translations, metadata, icons }; + export { branding, config, errorLogging, icons, metadata, modules, okapi, translations }; `; logger.log('writing virtual module...', stripesVirtualModule); this.virtualModule.writeModule('node_modules/stripes-config.js', stripesVirtualModule); } - - processWarnings(compilation, callback) { - if (this.warnings.length) { - compilation.warnings.push(new StripesBuildError(`stripes-config-plugin:\n ${this.warnings.join('\n ')}`)); - } - callback(); - } }; diff --git a/webpack/stripes-federation-plugin.js b/webpack/stripes-federation-plugin.js new file mode 100644 index 0000000..50fcf21 --- /dev/null +++ b/webpack/stripes-federation-plugin.js @@ -0,0 +1,38 @@ +// This webpack plugin wraps all other stripes webpack plugins to simplify inclusion within the webpack config +const spawn = require('child_process').spawn; +const path = require('path'); +const portfinder = require('portfinder'); + +const { locateStripesModule } = require('./module-paths'); + +portfinder.setBasePort(3002); + +module.exports = class StripesFederationPlugin { + constructor(stripesConfig) { + this.stripesConfig = stripesConfig; + } + + async startRemotes(modules) { + const ctx = process.cwd(); + + for (const moduleName in modules) { + const packageJsonPath = locateStripesModule(ctx, moduleName, {}, 'package.json'); + const basePath = path.dirname(packageJsonPath); + + portfinder.getPort((err, port) => { + const child = spawn(`yarn stripes federate --port ${port}`, { + cwd: basePath, + shell: true, + }); + + child.stdout.pipe(process.stdout); + }); + } + } + + apply() { + const { modules } = this.stripesConfig; + + this.startRemotes(modules); + } +}; diff --git a/webpack/stripes-node-api.js b/webpack/stripes-node-api.js index afffb6d..2b0d9d7 100644 --- a/webpack/stripes-node-api.js +++ b/webpack/stripes-node-api.js @@ -1,9 +1,11 @@ const build = require('./build'); const serve = require('./serve'); const transpile = require('./transpile'); +const federate = require('./federate'); module.exports = { build, serve, transpile, + federate, }; diff --git a/webpack/stripes-translations-plugin.js b/webpack/stripes-translations-plugin.js index c2dcbb2..2d04d21 100644 --- a/webpack/stripes-translations-plugin.js +++ b/webpack/stripes-translations-plugin.js @@ -16,23 +16,30 @@ function prefixKeys(obj, prefix) { module.exports = class StripesTranslationPlugin { constructor(options) { + // in module federation mode, we emit translations for the module being built and + // for any stripesDeps it has. + this.federate = options?.federate || false; + // Include stripes-core et al because they have translations - this.modules = { + // translations should come from the host application for stripes + // rather than being overwritten by consuming apps. + this.modules = this.federate ? {} : { '@folio/stripes-core': {}, '@folio/stripes-components': {}, '@folio/stripes-smart-components': {}, '@folio/stripes-form': {}, '@folio/stripes-ui': {}, }; - Object.assign(this.modules, options.modules); - this.languageFilter = options.config.languages || []; + + Object.assign(this.modules, options?.modules); + this.languageFilter = options?.config?.languages || []; logger.log('language filter', this.languageFilter); } apply(compiler) { // Used to help locate modules this.context = compiler.context; - // 'publicPath' is not present when running tests via karma-webpack + // 'publicPath' is not present when running tests via karma-webpack // so when running in test mode use absolute 'path'. this.publicPath = process.env.NODE_ENV !== 'test' ? compiler.options.output.publicPath : `./absolute${compiler.options.output.path}`; this.aliases = compiler.options.resolve.alias; @@ -44,27 +51,30 @@ module.exports = class StripesTranslationPlugin { new webpack.ContextReplacementPlugin(/moment[/\\]locale/, filterRegex).apply(compiler); } - // Hook into stripesConfigPlugin to supply paths to translation files - // and gather additional modules from stripes.stripesDeps - StripesConfigPlugin.getPluginHooks(compiler).beforeWrite.tap({ name: 'StripesTranslationsPlugin', context: true }, (context, config) => { - // Add stripesDeps - for (const [key, value] of Object.entries(context.stripesDeps)) { - // TODO: merge translations from all versions of stripesDeps - this.modules[key] = value[value.length - 1]; + if (this.federate) { + const packageJsonPath = path.join(this.context, 'package.json'); + const packageJson = StripesTranslationPlugin.loadFile(packageJsonPath); + + this.modules[packageJson.name] = {}; + if (packageJson) { + const stripesDeps = packageJson?.stripes?.stripesDeps; + if (stripesDeps) { + stripesDeps.forEach((dep) => { + // TODO: merge translations from all versions of stripesDeps + this.modules[dep] = {}; + }); + } } - // Gather all translations available in each module - const allTranslations = this.gatherAllTranslations(); - - const fileData = this.generateFileNames(allTranslations); - const allFiles = _.mapValues(fileData, data => data.browserPath); + compiler.hooks.thisCompilation.tap('StripesTranslationsPlugin', (compilation) => { + compilation.hooks.processAssets.tap({ + name: 'StripesTranslationsPlugin', + stage: compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS + }, () => { - config.translations = allFiles; - logger.log('stripesConfigPluginBeforeWrite', config.translations); + const allTranslations = this.gatherAllTranslations(); + const fileData = this.generateFileNames(allTranslations, false); - compiler.hooks.thisCompilation.tap('StripesTranslationsPlugin', (compilation) => { - // Emit merged translations to the output directory - compilation.hooks.processAssets.tap('StripesTranslationsPlugin', () => { Object.keys(allTranslations).forEach((language) => { logger.log(`emitting translations for ${language} --> ${fileData[language].emitPath}`); const content = JSON.stringify(allTranslations[language]); @@ -75,7 +85,34 @@ module.exports = class StripesTranslationPlugin { }); }); }); - }); + } else { + // Hook into stripesConfigPlugin to supply paths to translation files + // and gather additional modules from stripes.stripesDeps + StripesConfigPlugin.getPluginHooks(compiler).beforeWrite.tap({ name: 'StripesTranslationsPlugin', context: true }, (context, config) => { + // Gather all translations available in each module + const allTranslations = this.gatherAllTranslations(); + + const fileData = this.generateFileNames(allTranslations); + const allFiles = _.mapValues(fileData, data => data.browserPath); + + config.translations = allFiles; + logger.log('stripesConfigPluginBeforeWrite', config.translations); + + compiler.hooks.thisCompilation.tap('StripesTranslationsPlugin', (compilation) => { + // Emit merged translations to the output directory + compilation.hooks.processAssets.tap('StripesTranslationsPlugin', () => { + Object.keys(allTranslations).forEach((language) => { + logger.log(`emitting translations for ${language} --> ${fileData[language].emitPath}`); + const content = JSON.stringify(allTranslations[language]); + compilation.assets[fileData[language].emitPath] = { + source: () => content, + size: () => content.length, + }; + }); + }); + }); + }); + } } // Locate each module's translations directory (current) or package.json data (fallback) @@ -176,14 +213,14 @@ module.exports = class StripesTranslationPlugin { } // Assign output path names for each to be accessed later by stripes-config-plugin - generateFileNames(allTranslations) { + generateFileNames(allTranslations, useSuffix = true) { const files = {}; - const timestamp = Date.now(); // To facilitate cache busting, could also generate a hash + const timestamp = useSuffix ? Date.now() : ''; // To facilitate cache busting, could also generate a hash Object.keys(allTranslations).forEach((language) => { files[language] = { // Fetching from the browser must take into account public path. The replace regex removes double slashes browserPath: `${this.publicPath}/translations/${language}-${timestamp}.json`.replace(/\/\//, '/'), - emitPath: `translations/${language}-${timestamp}.json`, + emitPath: `translations/${language}${timestamp ? `-${timestamp}` : ''}.json`, }; }); return files; diff --git a/webpack/utils.js b/webpack/utils.js index b29bfda..d106d65 100644 --- a/webpack/utils.js +++ b/webpack/utils.js @@ -14,8 +14,20 @@ const processExternals = (peerDeps) => { }, {}); }; +const processShared = (shared, options = {}) => { + return Object.keys(shared).reduce((acc, name) => { + acc[name] = { + requiredVersion: shared[name], + ...options + }; + + return acc; + }, {}); +}; + module.exports = { processExternals, isDevelopment, isProduction, + processShared, };