diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eab2eb..31b2491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ * Unlock `esbuild-loader` from `~3.0.0`, bumping to `^4.2.2`. Refs STRWEB-95. * Prune dead code, `stripes.js` and its dep `commander`. Refs STRWEB-134. * Provide `getDynamicModule`, returning a module via `import()`. Refs STRWEB-137. -* Add `subscribesTo` field to module metadata. Refs STRWEB-143. +* * Add `subscribesTo` field to module metadata. Refs STRWEB-143. +* Add `StripesLocalFederation` plugin, inject module federation plugin for federated platforms and modules. Refs STRIPES-861. +* Adjust `StripesTranslationsPlugin` for working at the module level and including translations from `stripesDeps`. Refs STRIPES-861. ## [6.0.0](https://github.com/folio-org/stripes-webpack/tree/v6.0.0) (2025-02-24) [Full Changelog](https://github.com/folio-org/stripes-webpack/compare/v5.2.0...v6.0.0) diff --git a/consts.js b/consts.js new file mode 100644 index 0000000..6510aac --- /dev/null +++ b/consts.js @@ -0,0 +1,59 @@ +// 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-components': '^13.1.0', + '@folio/stripes-connect': '^10.0.1', + '@folio/stripes-core': '^11.1.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', +}; + +/** getHostAppSingletons +* get singletons from stripes-core package.json on Github. +*/ +const getHostAppSingletons = () => { + let platformSingletons = {}; + + const handlePkgData = (corePkg) => { + const pkgObject = corePkg.data ? JSON.parse(corePkg.data) : corePkg; + const stripesCoreVersion = pkgObject.version; + platformSingletons['@folio/stripes-core'] = `~${stripesCoreVersion}`; + Object.keys(singletons).forEach(dep => { + const depVersion = pkgObject.peerDependencies[dep]; + if (depVersion) { + platformSingletons[dep] = depVersion; + } + }); + platformSingletons = { ...platformSingletons, ...singletons }; + } + + let corePkg; + // try to get the locally installed stripes-core + try { + corePkg = require('@folio/stripes-core/package.json'); + } catch (e) { + corePkg = singletons; + throw new Error('Error retrieving singletons list from platform. Falling back to static list'); + } + + handlePkgData(corePkg); + return platformSingletons; +} + +const defaultentitlementUrl = 'http://localhost:3001/registry'; + +module.exports = { + defaultentitlementUrl, + singletons, + getHostAppSingletons +}; diff --git a/package.json b/package.json index c79f806..8782dc6 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@cerner/duplicate-package-checker-webpack-plugin": "~2.1.0", "@csstools/postcss-global-data": "^3.0.0", "@csstools/postcss-relative-color-syntax": "^3.0.7", + "@octokit/rest": "^19.0.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", "@svgr/webpack": "^8.1.0", "add-asset-html-webpack-plugin": "^6.0.0", @@ -38,7 +39,9 @@ "babel-loader": "^9.1.3", "buffer": "^6.0.3", "connect-history-api-fallback": "^1.3.0", + "copy-webpack-plugin": "^13.0.1", "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 +55,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 +74,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" diff --git a/test/webpack/stripes-config-plugin.spec.js b/test/webpack/stripes-config-plugin.spec.js index cb66626..95fdfdb 100644 --- a/test/webpack/stripes-config-plugin.spec.js +++ b/test/webpack/stripes-config-plugin.spec.js @@ -136,7 +136,7 @@ describe('The stripes-config-plugin', function () { expect(writeModuleArgs[0]).to.be.a('string').that.equals('node_modules/stripes-config.js'); // TODO: More thorough analysis of the generated virtual module - expect(writeModuleArgs[1]).to.be.a('string').with.match(/export { okapi, config, modules, branding, errorLogging, translations, metadata, icons }/); + expect(writeModuleArgs[1]).to.be.a('string').with.match(/export { branding, config, errorLogging, icons, metadata, modules, okapi, translations }/); }); }); diff --git a/test/webpack/stripes-translations-plugin.spec.js b/test/webpack/stripes-translations-plugin.spec.js index b13c230..ebaf834 100644 --- a/test/webpack/stripes-translations-plugin.spec.js +++ b/test/webpack/stripes-translations-plugin.spec.js @@ -9,8 +9,8 @@ const StripesTranslationsPlugin = require('../../webpack/stripes-translations-pl // Stub the parts of the webpack compiler that the StripesTranslationsPlugin interacts with const compilerStub = { - apply: () => {}, - plugin: () => {}, + apply: () => { }, + plugin: () => { }, options: { output: { publicPath: '/', @@ -21,22 +21,22 @@ const compilerStub = { }, hooks: { beforeWrite: { - tap: () => {}, + tap: () => { }, }, emit: { - tapAsync: () => {}, + tapAsync: () => { }, }, processAssets: { - tap: () => {}, + tap: () => { }, }, thisCompilation: { - tap: () => {}, + tap: () => { }, }, contextModuleFactory: { - tap: () => {}, + tap: () => { }, }, afterResolve: { - tap: () => {}, + tap: () => { }, } } }; @@ -52,6 +52,8 @@ describe('The stripes-translations-plugin', function () { '@folio/checkout': {}, }, }; + + this.stripesFederateConfig = { ...this.stripesConfig, federate: true }; }); describe('constructor', function () { @@ -89,12 +91,12 @@ describe('The stripes-translations-plugin', function () { this.sandbox.spy(webpack.ContextReplacementPlugin.prototype, 'apply'); this.sandbox.spy(compilerStub.hooks.emit, 'tapAsync'); this.sandbox.spy(compilerStub.hooks.thisCompilation, 'tap'); - this.sandbox.stub(StripesTranslationsPlugin, 'loadFile').returns({ key1: 'Value 1', key2: 'Value 2' }); + this.sandbox.stub(StripesTranslationsPlugin, 'loadFile').returns({ key1: 'Value 1', key2: 'Value 2', name: 'testPackage', stripes: { stripesDeps: ['stripes-federate-dependency'] } }); this.compilationStub = { assets: {}, hooks: { processAssets: { - tap: () => {} + tap: () => { } }, }, }; @@ -131,6 +133,15 @@ describe('The stripes-translations-plugin', function () { expect(this.sut.modules).to.be.an('object').with.property('stripes-dependency'); }); + it('includes certain modules and stripes-deps in "federate" mpode', function () { + // federate mode is per-module, so the plugin executes outside of StripesConfigPlugin, with its own hook. + this.sut = new StripesTranslationsPlugin(this.stripesFederateConfig); + this.sut.apply({ ...compilerStub, context: __dirname }); + + expect(this.sut.modules).to.be.an('object').with.property('testPackage'); + expect(this.sut.modules).to.be.an('object').with.property('stripes-federate-dependency'); + }); + it('generates an emit function with all translations', function () { this.sut = new StripesTranslationsPlugin(this.stripesConfig); this.sut.apply(compilerStub); @@ -156,6 +167,30 @@ describe('The stripes-translations-plugin', function () { expect(emitFiles).to.match(/translations\/fr-\d+\.json/); }); + it('generates an emit function with all translations (federate mode)', function () { + this.sut = new StripesTranslationsPlugin(this.stripesFederateConfig); + this.sut.apply({ ...compilerStub, context: __dirname }); + + // Get the callback passed to 'thisCompilation' hook + const pluginArgs = compilerStub.hooks.thisCompilation.tap.getCall(0).args; + const compilerCallback = pluginArgs[1]; + + compilerCallback(this.compilationStub); + + const compilationArgs = this.compilationStub.hooks.processAssets.tap.getCall(0).args; + const compilationCallback = compilationArgs[1]; + + // Call it and observe the modification to compilation.asset + compilationCallback(); + + const emitFiles = Object.keys(this.compilationStub.assets); + + expect(emitFiles).to.have.length(3); + expect(emitFiles).to.match(/translations\/en-\d+\.json/); + expect(emitFiles).to.match(/translations\/es-\d+\.json/); + expect(emitFiles).to.match(/translations\/fr-\d+\.json/); + }); + it('applies ContextReplacementPlugins when language filters are set', function () { this.sut = new StripesTranslationsPlugin(this.stripesConfig); this.sut.languageFilter = ['en']; diff --git a/webpack.config.base.js b/webpack.config.base.js index 0be1037..ecee96e 100644 --- a/webpack.config.base.js +++ b/webpack.config.base.js @@ -65,6 +65,7 @@ const baseConfig = { }), new webpack.EnvironmentPlugin(['NODE_ENV']), new RemoveEmptyScriptsPlugin(), + new webpack.ManifestPlugin({ entrypoints: true }), ], module: { rules: [ @@ -162,7 +163,7 @@ const buildConfig = (modulePaths) => { test: /\.css$/, exclude: [cssDistPathRegex], use: [ - { loader: isProduction ? MiniCssExtractPlugin.loader : 'style-loader' }, + { loader: isProduction ? MiniCssExtractPlugin.loader : 'style-loader' }, { loader: 'css-loader', options: { diff --git a/webpack.config.cli.dev.js b/webpack.config.cli.dev.js index 19dad06..e2af356 100644 --- a/webpack.config.cli.dev.js +++ b/webpack.config.cli.dev.js @@ -9,7 +9,9 @@ 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 { getHostAppSingletons } = require('./consts'); +const { ModuleFederationPlugin } = require('webpack').container; +const { processShared } = require('./webpack/utils'); const useBrowserMocha = () => { return tryResolve('mocha/mocha-es2018.js') ? 'mocha/mocha-es2018.js' : 'mocha'; @@ -56,10 +58,17 @@ const buildConfig = (stripesConfig) => { if (utils.isDevelopment) { devConfig.plugins = devConfig.plugins.concat([ new webpack.HotModuleReplacementPlugin(), - new ReactRefreshWebpackPlugin() + new ReactRefreshWebpackPlugin(), ]); } + // Enable module federation, setting up the host platform to share singletons (react, stripes-core, etc) with remote modules. + if (stripesConfig.okapi.entitlementUrl) { + const hostAppSingletons = getHostAppSingletons(); + const shared = processShared(hostAppSingletons, { singleton: true, eager: true }); + devConfig.plugins.push(new ModuleFederationPlugin({ name: 'host', shared })); + } + // This alias avoids a console warning for react-dom patch devConfig.resolve.alias.process = 'process/browser.js'; devConfig.resolve.alias['mocha'] = useBrowserMocha(); diff --git a/webpack.config.cli.prod.js b/webpack.config.cli.prod.js index c558aed..6675680 100644 --- a/webpack.config.cli.prod.js +++ b/webpack.config.cli.prod.js @@ -9,6 +9,10 @@ const buildBaseConfig = require('./webpack.config.base'); const cli = require('./webpack.config.cli'); const esbuildLoaderRule = require('./webpack/esbuild-loader-rule'); const { getModulesPaths, getStripesModulesPaths, getTranspiledModules } = require('./webpack/module-paths'); +const { processShared } = require('./webpack/utils'); +const { ModuleFederationPlugin } = require('webpack').container; +const { getHostAppSingletons } = require('./consts'); + const buildConfig = (stripesConfig, options = {}) => { const modulePaths = getModulesPaths(stripesConfig.modules); @@ -54,6 +58,15 @@ const buildConfig = (stripesConfig, options = {}) => { }), ]); + // build platform with Module Federation if --federate flag is passed + if (options.federate) { + const singletons = getHostAppSingletons(); + const shared = processShared(singletons, { singleton: true, eager: true }); + prodConfig.plugins.push( + new ModuleFederationPlugin({ name: 'host', shared }) + ); + } + prodConfig.optimization = { mangleWasmImports: false, minimizer: [ diff --git a/webpack.config.federate.remote.js b/webpack.config.federate.remote.js new file mode 100644 index 0000000..00cdfda --- /dev/null +++ b/webpack.config.federate.remote.js @@ -0,0 +1,178 @@ +// This configuration file is used for building individual ui modules for a +// federated module platform setup. +// "icons", "translations", "sound" folders are statically hosted in the devServer config. +// "icons" and "sound" directories, with subfolders are copied to the output folder for a production build. + +const path = require('path'); +const webpack = require('webpack'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const CopyPlugin = require("copy-webpack-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 typescriptLoaderRule = require('./webpack/typescript-loader-rule') +const { getHostAppSingletons } = require('./consts'); + +const buildConfig = (metadata, options) => { + 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 configSingletons = getHostAppSingletons(); + const shared = processShared(configSingletons, { singleton: true }); + + const config = { + name, + mode: options.mode || 'development', + entry: mainEntry, + output: { + publicPath: 'auto', // webpack will determine publicPath of script at runtime. + path: options.outputPath ? path.resolve(options.outputPath) : undefined + }, + stats: { + errorDetails: true + }, + resolve: { + extensions: ['.js', '.json', '.ts', '.tsx'], + }, + module: { + rules: [ + typescriptLoaderRule, + esbuildLoaderRule(stripesModulePaths), + { + test: /\.(woff2?)$/, + type: 'asset/resource', + generator: { + filename: './fonts/[name].[contenthash].[ext]', + }, + }, + { + test: /\.(mp3|m4a)$/, + type: 'asset/resource', + generator: { + filename: './sound/[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$/, + 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 + }), + ] + }; + + // for a build/production mode copy sounds and icons to the output folder... + if (options.mode === 'production') { + config.plugins.push( + new CopyPlugin({ + patterns: [ + { from: 'sound', to: 'sound', noErrorOnMissing: true }, + { from: 'icons', to: 'icons', noErrorOnMissing: true } + ] + }) + ) + } else { + // in development mode, setup the devserver... + config.devtool = 'inline-source-map'; + config.devServer = { + port: port, + open: false, + headers: { + 'Access-Control-Allow-Origin': '*', + }, + static: [ + { + directory: translationsPath, + publicPath: '/translations' + }, + { + directory: iconsPath, + publicPath: '/icons' + }, + { + directory: soundsPath, + publicPath: '/sounds' + }, + ] + } + } + + if (options.minify === false) { + config.optimization = config.optimization || {}; + config.optimization.minimize = false; + } + + return config; +} + +module.exports = buildConfig; diff --git a/webpack/build.js b/webpack/build.js index fb058a9..6a50305 100644 --- a/webpack/build.js +++ b/webpack/build.js @@ -5,14 +5,35 @@ const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin'); const applyWebpackOverrides = require('./apply-webpack-overrides'); const logger = require('./logger')(); const buildConfig = require('../webpack.config.cli.prod'); +const federate = require('./federate'); const sharedStylesConfig = require('../webpack.config.cli.shared.styles'); const platformModulePath = path.join(path.resolve(), 'node_modules'); module.exports = function build(stripesConfig, options) { - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { logger.log('starting build...'); - let config = buildConfig(stripesConfig, options); + const buildCallback = (err, stats) => { + if (err) { + console.error(err.stack || err); + if (err.details) { + console.error(err.details); + } + reject(err); + } else { + resolve(stats); + } + }; + + let config; + if (options.context.isUiModule && options.federate) { + return federate( + stripesConfig, + { ...options, build: true, mode: 'production' }, + buildCallback); + } else { + config = buildConfig(stripesConfig, options) + } config = sharedStylesConfig(config, {}); @@ -73,16 +94,6 @@ module.exports = function build(stripesConfig, options) { logger.log('assign final webpack config', config); - webpack(config, (err, stats) => { - if (err) { - console.error(err.stack || err); - if (err.details) { - console.error(err.details); - } - reject(err); - } else { - resolve(stats); - } - }); + webpack(config, buildCallback); }); }; diff --git a/webpack/federate.js b/webpack/federate.js new file mode 100644 index 0000000..605a5a0 --- /dev/null +++ b/webpack/federate.js @@ -0,0 +1,94 @@ +const path = require('path'); +const webpack = require('webpack'); +const WebpackDevServer = require('webpack-dev-server'); +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(stripesConfig, options = {}, callback = () => { }) { + logger.log('starting federation...'); + const { entitlementUrl } = stripesConfig.okapi; + const packageJsonPath = tryResolve(path.join(process.cwd(), 'package.json')); + + if (!packageJsonPath) { + console.error('package.json not found'); + process.exit(); + } + + // publicPath for how remoteEntry will be accessed. + let url; + const port = options.port ?? await portfinder.getPortPromise(); + const host = options.host ?? `http://localhost`; + if (options.publicPath) { + url = `${options.publicPath}/remoteEntry.js` + } else { + 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, + location: url, + name, + id: `${name}-${version}`, + main, + ...stripesRest, + }; + + const config = buildConfig(metadata, options); + + if (options.build) { // build only + webpack(config, callback); + return; + } + + const requestHeader = { + "Content-Type": "application/json", + }; + + // update registry + await fetch( + entitlementUrl, { + method: 'POST', + headers: requestHeader, + body: JSON.stringify(metadata), + }) + .catch(error => { + console.error(`Registry not found. Please check ${entitlementUrl}`); + process.exit(); + }); + + const compiler = webpack(config); + const server = new WebpackDevServer(config.devServer, compiler); + console.log(`Starting remote server on port ${port}`); + + + compiler.hooks.shutdown.tapPromise('AsyncShutdownHook', async (stats) => { + try { + await fetch(entitlementUrl, { + method: 'DELETE', + headers: requestHeader, + body: JSON.stringify(metadata), + }).catch(error => { + throw new Error(error); + }); + } catch (error) { + console.error(`registry not found. Please check ${entitlementUrl}`); + } + }); + + // serve command expects a promise... + return server.start(); +}; 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..99e483c --- /dev/null +++ b/webpack/registryServer.js @@ -0,0 +1,57 @@ +const express = require('express'); +const cors = require('cors'); + +// Registry data +const registry = { + discovery: [{ + id: 'folio_stripes-1.0', + version: '1.0', + name: 'folio_stripes', + url: 'http://localhost:3000' + }] +}; + +const registryServer = { + start: (url, tenant = 'diku') => { + 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; + + if (registry.discovery.findIndex(r => r.name === name) === -1) { + registry.discovery.push(metadata) + } + + res.status(200).send(`Remote ${name} metadata updated`); + }); + + // return entire registry for machines + app.get('/registry', (_, res) => res.json(registry)); + + app.get(`/registry/entitlements/${tenant}/applications`, (_, 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; + + registry.discovery = registry.discovery.filter(r => r.name !== name); + + res.status(200).send(`Remote ${name} removed`); + }); + + const port = new URL(url).port || 3001; + app.listen(port, () => { + console.log('Starting registry server at http://localhost:3001'); + }); + } +}; + +module.exports = registryServer; diff --git a/webpack/serve.js b/webpack/serve.js index ef64a1c..bef6c4b 100644 --- a/webpack/serve.js +++ b/webpack/serve.js @@ -1,6 +1,7 @@ const webpack = require('webpack'); const path = require('path'); const express = require('express'); +const cors = require('cors'); const webpackDevMiddleware = require('webpack-dev-middleware'); const webpackHotMiddleware = require('webpack-hot-middleware'); const connectHistoryApiFallback = require('connect-history-api-fallback'); @@ -9,6 +10,8 @@ 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 federate = require('./federate'); const cwd = path.resolve(); const platformModulePath = path.join(cwd, 'node_modules'); @@ -16,15 +19,40 @@ const coreModulePath = path.join(__dirname, '..', 'node_modules'); const serverRoot = path.join(__dirname, '..'); module.exports = function serve(stripesConfig, options) { + // serving a locally federated module + if (options.federate && options.context.isUiModule) { + // override default port 3000 option, as locally federated modules will be on >= 3002... + options.port = options.port !== 3000 ? options.port : undefined; + return federate(stripesConfig, options); + } + if (typeof stripesConfig.okapi !== 'object') throw new Error('Missing Okapi config'); if (typeof stripesConfig.okapi.url !== 'string') throw new Error('Missing Okapi URL'); if (stripesConfig.okapi.url.endsWith('/')) throw new Error('Trailing slash in Okapi URL will prevent Stripes from functioning'); - return new Promise((resolve) => { + return new Promise(async (resolve) => { logger.log('starting serve...'); const app = express(); + app.use(express.json()); + app.use(cors()); + + // stripes module registry + if (options.federate && stripesConfig.okapi.entitlementUrl) { + const { entitlementUrl, tenant } = stripesConfig.okapi; + + // If the entitlement URL points to 'localhost', start a local registry for development/debug. + // For production, entitlementUrl will point to some non-local endpoint and the UI will fetch accordingly. + if (entitlementUrl.includes('localhost')) { + try { + registryServer.start(entitlementUrl, tenant); + } + catch (e) { + console.error(e) + } + } + } - let config = buildConfig(stripesConfig); + let config = await buildConfig(stripesConfig); config = sharedStylesConfig(config, {}); @@ -68,7 +96,6 @@ module.exports = function serve(stripesConfig, options) { })); app.use(webpackHotMiddleware(compiler)); - app.listen(port, host, (err) => { if (err) { console.log(err); diff --git a/webpack/stripes-config-plugin.js b/webpack/stripes-config-plugin.js index a42005f..74fb359 100644 --- a/webpack/stripes-config-plugin.js +++ b/webpack/stripes-config-plugin.js @@ -46,8 +46,14 @@ module.exports = class StripesConfigPlugin { apply(compiler) { const enabledModules = this.options.modules; logger.log('enabled modules:', enabledModules); - const { config, metadata, icons, stripesDeps, warnings } = stripesModuleParser.parseAllModules(enabledModules, compiler.context, compiler.options.resolve.alias, this.lazy); - this.mergedConfig = Object.assign({}, this.options, { modules: config }); + const { config, metadata, icons, stripesDeps, warnings, lazyImports } = stripesModuleParser.parseAllModules(enabledModules, compiler.context, compiler.options.resolve.alias, this.lazy); + const modulesInitialState = { + app: [], + handler: [], + plugin: [], + settings: [], + }; + this.mergedConfig = Object.assign({ modules: modulesInitialState }, this.options, { modules: config }); this.metadata = metadata; this.icons = icons; this.warnings = warnings; @@ -85,7 +91,7 @@ module.exports = class StripesConfigPlugin { 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); diff --git a/webpack/stripes-translations-plugin.js b/webpack/stripes-translations-plugin.js index c2dcbb2..c7ef2aa 100644 --- a/webpack/stripes-translations-plugin.js +++ b/webpack/stripes-translations-plugin.js @@ -16,23 +16,28 @@ function prefixKeys(obj, prefix) { module.exports = class StripesTranslationPlugin { constructor(options) { + 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 +49,39 @@ 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]; + // In module federation mode, we emit translations for the module being built and + // for any stripesDeps it has. Since the translations for 'stripes-core', components, form, etc are + // provided by a host application, we do not include them here. + // Translations are loaded at runtime from the built static 'translations' directory when the remote itself is loaded. + // In a monolithic build, StripesTranslationsPlugin is included in StripesConfigPlugin as + // its list of generated files is passed to the `stripes-config` virtual module as its `translations` object. + // In the monolithic build, the `stripes-config` virtual module's file path information is used to load translations. + 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); + // for usage at the module level, this plugin is used independently of the `StripesConfigPlugin`, so we + // register hooks under itself rather then the config plugin. + 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 +92,40 @@ 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) => { + // Add stripesDeps to the list of modules to load translations from + for (const [key, value] of Object.entries(context.stripesDeps)) { + // TODO: merge translations from all versions of stripesDeps + this.modules[key] = value[value.length - 1]; + } + + // 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) @@ -84,7 +134,12 @@ module.exports = class StripesTranslationPlugin { for (const mod of Object.keys(this.modules)) { // translations from module dependencies may need to be located relative to their dependent (eg. in yarn workspaces) const locateContext = this.modules[mod].resolvedPath || this.context; - const modPackageJsonPath = modulePaths.locateStripesModule(locateContext, mod, this.aliases, 'package.json'); + let modPackageJsonPath = modulePaths.locateStripesModule(locateContext, mod, this.aliases, 'package.json'); + + // if this is a module-level build of a cloned module, the package.json will be in the current folder/context. + if (!modPackageJsonPath && this.federate) { + modPackageJsonPath = path.join(this.context, 'package.json'); + } if (modPackageJsonPath) { const moduleName = StripesTranslationPlugin.getModuleName(mod); @@ -176,14 +231,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/tsconfig.json b/webpack/tsconfig.json index 9d3be5c..fc35d74 100644 --- a/webpack/tsconfig.json +++ b/webpack/tsconfig.json @@ -3,7 +3,10 @@ "noImplicitAny": true, "esModuleInterop": true, "jsx": "react", - "lib": ["esnext", "dom"], + "lib": [ + "esnext", + "dom" + ], "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true @@ -16,7 +19,9 @@ "../node_modules/@folio/**/*.ts", "../node_modules/@folio/**/*.tsx", "../../node_modules/@folio/**/*.ts", - "../../node_modules/@folio/**/*.tsx" + "../../node_modules/@folio/**/*.tsx", + "../../../../src/**/*.ts", + "../../../../src/**/*.tsx" ], "exclude": [ "../../**/*.test.ts", @@ -32,4 +37,4 @@ "../../node_modules/@folio/**/test/**/*.ts", "../../node_modules/@folio/**/test/**/*.tsx" ] -} +} \ No newline at end of file diff --git a/webpack/utils.js b/webpack/utils.js index b29bfda..7133f8d 100644 --- a/webpack/utils.js +++ b/webpack/utils.js @@ -1,5 +1,14 @@ const isDevelopment = process.env.NODE_ENV === 'development'; const isProduction = process.env.NODE_ENV === 'production'; + +// processExternals +// Accepts a list of peerDeps in the shape of an object +// with { [packageName]: [version] } +// this generates configuration for setting the peer dep as +// an 'external' dependency for webpack, meaning it won't be bundled, +// but will be expected to exist where the bundle is executed. +// the different module types adjust the way webpack transforms the code when +// an external module is encountered within a particular module. const processExternals = (peerDeps) => { return Object.keys(peerDeps).reduce((acc, name) => { acc[name] = { @@ -14,8 +23,26 @@ const processExternals = (peerDeps) => { }, {}); }; +// processShared +// This function takes an object of shared dependencies in the shape of +// { [packageName]: [version] } ex { '@folio/stripes': '^9.3.0' } +// and applies additional options for the module federation configuration, +// like setting the shared items as singletons, or using the 'eager' consumption +// setting (chunks are included in the initial bundle whether than split out/lazy loaded) +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, };