diff --git a/packages/infrastructure/webpack/README.md b/packages/infrastructure/webpack/README.md new file mode 100644 index 0000000000..365b640102 --- /dev/null +++ b/packages/infrastructure/webpack/README.md @@ -0,0 +1,76 @@ +# @k8slens/webpack + +This package contains webpack configurations for Lens packages. + +## Install + +``` +$ npm install @k8slens/webpack +``` + +## Features + +### Configurations + +### Node package +This configuration should be used when creating package that will be executed within **Node** environment. + +**webpack.config.js** +```javascript +module.exports = require("@k8slens/webpack").configForNode; +``` +### React package +This configuration should be used when creating package tha will be executed within **Browser** environment. + +**webpack.config.js** +```javascript +module.exports = require("@k8slens/webpack").configForReact; +``` + +### Multi export package + +This configuration should be used when package contains **multiple entrypoint** e.g. for different environments. You need to add `lensMultiExportConfig` to `package.json` with configuration. Note that also `exports` property needs to be set, but the correct values are generated from `lensMultiExportConfig` when using `lens-build` -script. + +**webpack.config.js** +```javascript +const packageJson = require("./package.json"); + +module.exports = require("@k8slens/webpack").getMultiExportConfig(packageJson); +``` + +**package.json** +```json +{ + "lensMultiExportConfig": { + "./main": { + "buildType": "node", + "entrypoint": "./src/main/index.ts" + }, + "./renderer": { + "buildType": "react", + "entrypoint": "./src/renderer/index.ts" + } + }, + + "exports": { + "./main": { + "types": "./build/main/index.d.ts", + "require": "./build/main/index.js", + "import": "./build/main/index.js", + "default": "./build/main/index.js" + }, + "./renderer": { + "types": "./build/renderer/index.d.ts", + "require": "./build/renderer/index.js", + "import": "./build/renderer/index.js", + "default": "./build/renderer/index.js" + } + } +} +``` + +## Scripts + +1. `lens-build` which builds the packages +2. `lens-remove-build` which removes the build directory from packages. It's useful for cleaning up. + diff --git a/packages/infrastructure/webpack/bin/build.sh b/packages/infrastructure/webpack/bin/build.sh new file mode 100755 index 0000000000..589acdeab2 --- /dev/null +++ b/packages/infrastructure/webpack/bin/build.sh @@ -0,0 +1,2 @@ +set -e +webpack $@ diff --git a/packages/infrastructure/webpack/bin/remove-build.sh b/packages/infrastructure/webpack/bin/remove-build.sh new file mode 100755 index 0000000000..f2421c500d --- /dev/null +++ b/packages/infrastructure/webpack/bin/remove-build.sh @@ -0,0 +1 @@ +rm -rfv build diff --git a/packages/infrastructure/webpack/index.js b/packages/infrastructure/webpack/index.js new file mode 100644 index 0000000000..978c6b1526 --- /dev/null +++ b/packages/infrastructure/webpack/index.js @@ -0,0 +1,5 @@ +module.exports = { + configForNode: require("./src/node-config"), + configForReact: require("./src/react-config"), + getMultiExportConfig: require("./src/get-multi-export-config"), +}; diff --git a/packages/infrastructure/webpack/jest.config.js b/packages/infrastructure/webpack/jest.config.js new file mode 100644 index 0000000000..89c15cd6f4 --- /dev/null +++ b/packages/infrastructure/webpack/jest.config.js @@ -0,0 +1,8 @@ +const { configForNode } = + require("@k8slens/jest").monorepoPackageConfig(__dirname); + +module.exports = { + ...configForNode, + + collectCoverageFrom: [...configForNode.collectCoverageFrom], +}; diff --git a/packages/infrastructure/webpack/package.json b/packages/infrastructure/webpack/package.json new file mode 100644 index 0000000000..43640d143f --- /dev/null +++ b/packages/infrastructure/webpack/package.json @@ -0,0 +1,36 @@ +{ + "name": "@k8slens/webpack", + "private": false, + "version": "0.0.1", + "description": "Webpack configurations and scripts for Lens packages.", + "type": "commonjs", + "repository": { + "type": "git", + "url": "git+https://github.com/lensapp/lens.git" + }, + "main": "index.js", + "author": { + "name": "OpenLens Authors", + "email": "info@k8slens.dev" + }, + "license": "MIT", + "homepage": "https://github.com/lensapp/lens", + "bin": { + "lens-build": "bin/build.sh", + "lens-remove-build": "bin/remove-build.sh" + }, + "scripts": { + "test": "lens-test" + }, + "dependencies": { + "@types/webpack-env": "^1.18.0", + "css-loader": "^6.7.2", + "mini-css-extract-plugin": "^2.7.0", + "sass-loader": "^13.2.0", + "style-loader": "^3.3.1", + "ts-loader": "^9.4.1", + "webpack": "^5.75.0", + "webpack-cli": "^4.10.0", + "webpack-node-externals": "^3.0.0" + } +} diff --git a/packages/infrastructure/webpack/src/get-multi-export-config.js b/packages/infrastructure/webpack/src/get-multi-export-config.js new file mode 100644 index 0000000000..6f88d22d17 --- /dev/null +++ b/packages/infrastructure/webpack/src/get-multi-export-config.js @@ -0,0 +1,157 @@ +const nodeConfig = require("./node-config"); +const reactConfig = require("./react-config"); +const path = require("path"); +const { + map, + isEqual, + keys, + fromPairs, + toPairs, + reject, + values, + nth, + filter, +} = require("lodash/fp"); +const { pipeline } = require("@ogre-tools/fp"); + +module.exports = ( + packageJson, + dependencies = { workingDirectory: process.cwd(), nodeConfig, reactConfig } +) => { + if (!packageJson.lensMultiExportConfig) { + throw new Error( + `Tried to get multi export config for package "${packageJson.name}" but configuration is missing.` + ); + } + + const validBuildTypes = ["node", "react"]; + + const invalidBuildTypes = pipeline( + packageJson.lensMultiExportConfig, + values, + map((config) => config.buildType), + reject((buildType) => validBuildTypes.includes(buildType)) + ); + + if (invalidBuildTypes.length > 0) { + throw new Error( + `Tried to get multi export config for package "${ + packageJson.name + }" but build types "${invalidBuildTypes.join( + '", "' + )}" were not any of "${validBuildTypes.join('", "')}".` + ); + } + + const exportsWithMissingEntrypoint = pipeline( + packageJson.lensMultiExportConfig, + toPairs, + filter(([, config]) => !config.entrypoint), + map(nth(0)) + ); + + if (exportsWithMissingEntrypoint.length > 0) { + throw new Error( + `Tried to get multi export config for package "${ + packageJson.name + }" but entrypoint was missing for "${exportsWithMissingEntrypoint.join( + '", "' + )}".` + ); + } + + const expectedExports = pipeline( + packageJson.lensMultiExportConfig, + keys, + map(toExpectedExport), + fromPairs + ); + + if (!isEqual(expectedExports, packageJson.exports)) { + throw new Error( + `Tried to get multi export config but exports of package.json for "${ + packageJson.name + }" did not match exactly:\n\n${JSON.stringify(expectedExports, null, 2)}` + ); + } + + return pipeline( + packageJson.lensMultiExportConfig, + toPairs, + map(toExportSpecificWebpackConfigFor(dependencies)) + ); +}; + +const toExpectedExport = (externalImportPath) => { + const entrypointPath = `./${path.join( + "./build", + externalImportPath, + "index.js" + )}`; + + return [ + externalImportPath, + { + types: `./${path.join("./build", externalImportPath, "index.d.ts")}`, + + default: entrypointPath, + import: entrypointPath, + require: entrypointPath, + }, + ]; +}; + +const getRuleWithExportSpecificDeclarationDir = (rule, outputPath, rootDir) => { + return { + ...rule, + + options: { + ...rule.options, + + compilerOptions: { + ...rule.options.compilerOptions, + declarationDir: outputPath, + rootDir: rootDir, + }, + }, + }; +}; + +const toExportSpecificWebpackConfigFor = + (dependencies) => + ([externalImportPath, { buildType, entrypoint }]) => { + const baseConfig = + buildType === "node" ? dependencies.nodeConfig : dependencies.reactConfig; + + const outputDirectory = path.join( + dependencies.workingDirectory, + "build", + externalImportPath + ); + + return { + ...baseConfig, + name: entrypoint, + + entry: { + index: entrypoint, + }, + + output: { + ...baseConfig.output, + path: outputDirectory, + }, + + module: { + rules: baseConfig.module.rules.map((rule) => + rule.loader === "ts-loader" + ? getRuleWithExportSpecificDeclarationDir( + rule, + outputDirectory, + path.join(dependencies.workingDirectory, "src") + ) + : rule + ), + }, + }; + }; diff --git a/packages/infrastructure/webpack/src/get-multi-export-config.test.js b/packages/infrastructure/webpack/src/get-multi-export-config.test.js new file mode 100644 index 0000000000..e9b984730a --- /dev/null +++ b/packages/infrastructure/webpack/src/get-multi-export-config.test.js @@ -0,0 +1,300 @@ +import getMultiExportConfig from "./get-multi-export-config"; + +describe("get-multi-export-config", () => { + let actual; + let maximalPackageJson; + + beforeEach(() => { + maximalPackageJson = { + name: "some-name", + + lensMultiExportConfig: { + ".": { + buildType: "node", + entrypoint: "./index.ts", + }, + + "./some-entrypoint": { + buildType: "node", + entrypoint: "./some-entrypoint/index.ts", + }, + + "./some-other-entrypoint": { + buildType: "react", + entrypoint: "./some-other-entrypoint/index.ts", + }, + }, + + exports: { + ".": { + types: "./build/index.d.ts", + require: "./build/index.js", + import: "./build/index.js", + default: "./build/index.js", + }, + "./some-entrypoint": { + types: "./build/some-entrypoint/index.d.ts", + require: "./build/some-entrypoint/index.js", + import: "./build/some-entrypoint/index.js", + default: "./build/some-entrypoint/index.js", + }, + "./some-other-entrypoint": { + types: "./build/some-other-entrypoint/index.d.ts", + require: "./build/some-other-entrypoint/index.js", + import: "./build/some-other-entrypoint/index.js", + default: "./build/some-other-entrypoint/index.js", + }, + }, + }; + }); + + it("given maximal package.json, when creating configuration, works", () => { + actual = getMultiExportConfig(maximalPackageJson, { + workingDirectory: "/some-working-directory", + nodeConfig: nodeConfigStub, + reactConfig: reactConfigStub, + }); + + expect(actual).toEqual([ + { + name: "./index.ts", + stub: "node", + entry: { index: "./index.ts" }, + output: { some: "value", path: "/some-working-directory/build" }, + + module: { + rules: [ + { + test: "some-test", + loader: "ts-loader", + + options: { + compilerOptions: { + declaration: "some-declaration", + declarationDir: "/some-working-directory/build", + rootDir: "/some-working-directory/src", + }, + }, + }, + + { + some: "other-rule", + }, + ], + }, + }, + + { + name: "./some-entrypoint/index.ts", + stub: "node", + entry: { index: "./some-entrypoint/index.ts" }, + + output: { + some: "value", + path: "/some-working-directory/build/some-entrypoint", + }, + + module: { + rules: [ + { + test: "some-test", + loader: "ts-loader", + + options: { + compilerOptions: { + declaration: "some-declaration", + + declarationDir: + "/some-working-directory/build/some-entrypoint", + + rootDir: "/some-working-directory/src", + }, + }, + }, + + { + some: "other-rule", + }, + ], + }, + }, + + { + name: "./some-other-entrypoint/index.ts", + stub: "react", + entry: { index: "./some-other-entrypoint/index.ts" }, + + output: { + some: "other-value", + path: "/some-working-directory/build/some-other-entrypoint", + }, + + module: { + rules: [ + { + test: "some-test", + loader: "ts-loader", + + options: { + compilerOptions: { + declaration: "some-declaration", + + declarationDir: + "/some-working-directory/build/some-other-entrypoint", + + rootDir: "/some-working-directory/src", + }, + }, + }, + + { + some: "other-rule", + }, + ], + }, + }, + ]); + }); + + it("given maximal package.json but path for entrypoint in exports do not match output, when creating configuration, throws", () => { + maximalPackageJson.exports["./some-entrypoint"].default = "wrong-path"; + + expect(() => { + getMultiExportConfig(maximalPackageJson, { + workingDirectory: "/some-working-directory", + nodeConfig: nodeConfigStub, + reactConfig: reactConfigStub, + }); + }).toThrow( + 'Tried to get multi export config but exports of package.json for "some-name" did not match exactly:' + ); + }); + + it("given maximal package.json but exports do not match lens multi export config, when creating configuration, throws", () => { + maximalPackageJson.exports = {}; + + expect(() => { + getMultiExportConfig(maximalPackageJson, { + workingDirectory: "/some-working-directory", + nodeConfig: nodeConfigStub, + reactConfig: reactConfigStub, + }); + }).toThrow( + 'Tried to get multi export config but exports of package.json for "some-name" did not match exactly:' + ); + }); + + it("given maximal package.json but exports are missing, when creating configuration, throws", () => { + delete maximalPackageJson.exports; + + expect(() => { + getMultiExportConfig(maximalPackageJson, { + workingDirectory: "/some-working-directory", + nodeConfig: nodeConfigStub, + reactConfig: reactConfigStub, + }); + }).toThrow( + 'Tried to get multi export config but exports of package.json for "some-name" did not match exactly:' + ); + }); + + it("given maximal package.json but lens multi export config is missing, when creating configuration, throws", () => { + delete maximalPackageJson.lensMultiExportConfig; + + expect(() => { + getMultiExportConfig(maximalPackageJson, { + workingDirectory: "/some-working-directory", + nodeConfig: nodeConfigStub, + reactConfig: reactConfigStub, + }); + }).toThrow( + 'Tried to get multi export config for package "some-name" but configuration is missing.' + ); + }); + + it("given maximal package.json but a build type is incorrect, when creating configuration, throws", () => { + maximalPackageJson.lensMultiExportConfig["."].buildType = "some-invalid"; + + expect(() => { + getMultiExportConfig(maximalPackageJson, { + workingDirectory: "/some-working-directory", + nodeConfig: nodeConfigStub, + reactConfig: reactConfigStub, + }); + }).toThrow( + 'Tried to get multi export config for package "some-name" but build types "some-invalid" were not any of "node", "react".' + ); + }); + + it("given maximal package.json but entrypoint is missing, when creating configuration, throws", () => { + delete maximalPackageJson.lensMultiExportConfig["./some-entrypoint"] + .entrypoint; + + expect(() => { + getMultiExportConfig(maximalPackageJson, { + workingDirectory: "/some-working-directory", + nodeConfig: nodeConfigStub, + reactConfig: reactConfigStub, + }); + }).toThrow( + 'Tried to get multi export config for package "some-name" but entrypoint was missing for "./some-entrypoint".' + ); + }); +}); + +const nodeConfigStub = { + stub: "node", + output: { + some: "value", + path: "irrelevant", + }, + + module: { + rules: [ + { + test: "some-test", + loader: "ts-loader", + + options: { + compilerOptions: { + declaration: "some-declaration", + declarationDir: "irrelevant", + }, + }, + }, + + { + some: "other-rule", + }, + ], + }, +}; + +const reactConfigStub = { + stub: "react", + + output: { + some: "other-value", + path: "irrelevant", + }, + + module: { + rules: [ + { + test: "some-test", + loader: "ts-loader", + + options: { + compilerOptions: { + declaration: "some-declaration", + declarationDir: "irrelevant", + }, + }, + }, + + { + some: "other-rule", + }, + ], + }, +}; diff --git a/packages/infrastructure/webpack/src/node-config.js b/packages/infrastructure/webpack/src/node-config.js new file mode 100644 index 0000000000..1d20855df2 --- /dev/null +++ b/packages/infrastructure/webpack/src/node-config.js @@ -0,0 +1,69 @@ +const nodeExternals = require("webpack-node-externals"); +const path = require("path"); + +const buildDirectory = path.resolve(process.cwd(), "build"); + +module.exports = { + entry: { index: "./index.ts" }, + target: "node", + mode: "production", + + performance: { + maxEntrypointSize: 100000, + hints: "error", + }, + + resolve: { + extensions: [".ts", ".tsx"], + }, + + output: { + path: buildDirectory, + + filename: (pathData) => + pathData.chunk.name === "index" + ? "index.js" + : `${pathData.chunk.name}/index.js`, + + libraryTarget: "commonjs2", + }, + + externals: [ + nodeExternals({ modulesFromFile: true }), + + nodeExternals({ + modulesDir: path.resolve( + __dirname, + "..", + "..", + "..", + "..", + "node_modules" + ), + }), + ], + + externalsPresets: { node: true }, + + node: { + __dirname: true, + __filename: true, + }, + + module: { + rules: [ + { + test: /\.ts(x)?$/, + loader: "ts-loader", + + options: { + compilerOptions: { + rootDir: process.cwd(), + declaration: true, + declarationDir: buildDirectory, + }, + }, + }, + ], + }, +}; diff --git a/packages/infrastructure/webpack/src/react-config.js b/packages/infrastructure/webpack/src/react-config.js new file mode 100644 index 0000000000..22517ef30a --- /dev/null +++ b/packages/infrastructure/webpack/src/react-config.js @@ -0,0 +1,47 @@ +const nodeConfig = require("./node-config"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const path = require("path"); + +module.exports = { + ...nodeConfig, + + plugins: [ + // ...nodeConfig.plugins, + + new MiniCssExtractPlugin({ + filename: "[name].css", + }), + ], + + module: { + ...nodeConfig.module, + rules: [ + ...nodeConfig.module.rules, + + { + test: /\.s?css$/, + + use: [ + MiniCssExtractPlugin.loader, + { + loader: "css-loader", + options: { + sourceMap: false, + modules: { + auto: /\.module\./i, // https://github.com/webpack-contrib/css-loader#auto + mode: "local", // :local(.selector) by default + localIdentName: "[name]__[local]--[hash:base64:5]", + }, + }, + }, + { + loader: "sass-loader", + options: { + sourceMap: false, + }, + }, + ], + }, + ], + }, +};