diff --git a/.cspell.json b/.cspell.json index 156c2be7a33..74741b7a930 100644 --- a/.cspell.json +++ b/.cspell.json @@ -2,6 +2,7 @@ "version": "0.2", "language": "en,en-gb", "words": [ + "systemvars", "Atsumu", "autoprefixer", "barbaz", diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100755 new mode 100644 diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100755 new mode 100644 diff --git a/SERVE-OPTIONS-v5.md b/SERVE-OPTIONS-v5.md index 0dd6c384980..40eeab03c83 100644 --- a/SERVE-OPTIONS-v5.md +++ b/SERVE-OPTIONS-v5.md @@ -102,6 +102,7 @@ Options: --watch-files-reset Clear all items provided in 'watchFiles' configuration. Allows to configure list of globs/directories/files to watch for file changes. --no-web-socket-server Disallows to set web socket server and options. --web-socket-server-type Allows to set web socket server and options (by default 'ws'). + --dot-env Allows env support to webpack. Global options: --color Enable colors on console. diff --git a/package.json b/package.json index 0077f648e6e..052210a115c 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "get-port": "^5.1.1", "husky": "^8.0.1", "internal-ip": "^6.2.0", - "jest": "^29.4.1", + "jest": "^29.7.0", "jest-watch-typeahead": "^2.2.2", "lerna": "^6.0.1", "lint-staged": "^13.0.3", diff --git a/packages/webpack-cli/package.json b/packages/webpack-cli/package.json index 027ff3ad6df..660714a4f83 100644 --- a/packages/webpack-cli/package.json +++ b/packages/webpack-cli/package.json @@ -41,6 +41,7 @@ "colorette": "^2.0.14", "commander": "^10.0.1", "cross-spawn": "^7.0.3", + "dotenv": "^16.4.5", "envinfo": "^7.10.0", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", diff --git a/packages/webpack-cli/src/plugins/dotenv-webpack-plugin.ts b/packages/webpack-cli/src/plugins/dotenv-webpack-plugin.ts new file mode 100644 index 00000000000..9b1c559ff9c --- /dev/null +++ b/packages/webpack-cli/src/plugins/dotenv-webpack-plugin.ts @@ -0,0 +1,220 @@ +import dotenv from "dotenv"; +import { Compiler, DefinePlugin, cache } from "webpack"; + +interface EnvVariables { + [key: string]: string; +} +interface DotenvConfig { + paths?: string[]; + prefixes?: string[]; + systemvars?: boolean; + allowEmptyValues?: boolean; + expand?: boolean; + ignoreStub?: boolean; + safe?: boolean; +} + +const interpolate = (env: string, vars: EnvVariables): string => { + const matches = env.match(/\$([a-zA-Z0-9_]+)|\${([a-zA-Z0-9_]+)}/g) || []; + matches.forEach((match) => { + env = env.replace(match, interpolate(vars[match.replace(/\$|{|}/g, "")] || "", vars)); + }); + return env; +}; + +const isMainThreadElectron = (target: string | undefined): boolean => + !!target && target.startsWith("electron") && target.endsWith("main"); + +export class Dotenv { + #options: DotenvConfig; + #inputFileSystem!: any; + #compiler!: Compiler; + #logger: any; + #cache!: any; + constructor(config: DotenvConfig) { + this.#options = { + prefixes: ["process.env.", "import.meta.env."], + allowEmptyValues: true, + expand: true, + safe: true, + paths: process.env.NODE_ENV + ? [ + ".env", + ...(process.env.NODE_ENV === "test" ? [] : [".env.local"]), + `.env.[mode]`, + `.env.[mode].local`, + ] + : [], + ...config, + }; + } + + public apply(compiler: Compiler): void { + this.#inputFileSystem = compiler.inputFileSystem; + this.#logger = compiler.getInfrastructureLogger("dotenv-webpack-plugin"); + this.#compiler = compiler; + compiler.hooks.initialize.tap("dotenv-webpack-plugin", () => { + this.#execute(); + // Not sure if this part is is a correct approach + compiler.hooks.compilation.tap("dotenv-webpack-plugin", async (compilation) => { + this.#cache = await compilation.getCache("dotenv-webpack-plugin-cache"); + if (this.#cache) { + new DefinePlugin(this.#cache).apply(compiler); + } else { + this.#execute(); + } + }); + }); + } + + #execute() { + const variables = this.#gatherVariables(); + const target: string = + typeof this.#compiler.options.target == "string" ? this.#compiler.options.target : ""; + const data = this.#formatData({ + variables, + target: target, + }); + new DefinePlugin(data).apply(this.#compiler); + } + + #gatherVariables(compilation?: any): EnvVariables { + const { allowEmptyValues, safe } = this.#options; + const vars: EnvVariables = this.#initializeVars(); + + const { env, blueprint } = this.#getEnvs(compilation); + + Object.keys(blueprint).forEach((key) => { + const value = Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : env[key]; + + if ( + (typeof value === "undefined" || value === null || (!allowEmptyValues && value === "")) && + safe + ) { + compilation?.errors.push(new Error(`Missing environment variable: ${key}`)); + } else { + vars[key] = value; + } + if (safe) { + Object.keys(env).forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(vars, key)) { + vars[key] = env[key]; + } + }); + } + }); + + return vars; + } + + #initializeVars(): EnvVariables { + return this.#options.systemvars + ? Object.fromEntries(Object.entries(process.env).map(([key, value]) => [key, value ?? ""])) + : {}; + } + + #getEnvs(compilation?: any): { env: EnvVariables; blueprint: EnvVariables } { + const { paths, safe } = this.#options; + + const env: EnvVariables = {}; + let blueprint: EnvVariables = {}; + + for (const path of paths || []) { + const fileContent = this.#loadFile( + path.replace("[mode]", `${process.env.NODE_ENV || this.#compiler.options.mode}`), + compilation, + ); + Object.assign(env, dotenv.parse(fileContent)); + } + blueprint = env; + if (safe) { + for (const path of paths || []) { + const exampleContent = this.#loadFile( + `${path.replace( + "[mode]", + `${process.env.NODE_ENV || this.#compiler.options.mode}`, + )}.example`, + ); + blueprint = { ...blueprint, ...dotenv.parse(exampleContent) }; + } + } + return { env, blueprint }; + } + + #formatData({ + variables = {}, + target, + }: { + variables: EnvVariables; + target: string; + }): Record { + const { expand, prefixes } = this.#options; + + const preprocessedVariables: EnvVariables = Object.keys(variables).reduce( + (obj: EnvVariables, key: string) => { + let value = variables[key]; + if (expand) { + if (value.startsWith("\\$")) { + value = value.substring(1); + } else if (value.includes("\\$")) { + value = value.replace(/\\\$/g, "$"); + } else { + value = interpolate(value, variables); + } + } + obj[key] = JSON.stringify(value); + return obj; + }, + {}, + ); + + const formatted: Record = {}; + if (prefixes) { + prefixes.forEach((prefix) => { + Object.entries(preprocessedVariables).forEach(([key, value]) => { + formatted[`${prefix}${key}`] = value; + }); + }); + } + + const shouldStubEnv = + prefixes?.includes("process.env.") && this.#shouldStub({ target, prefix: "process.env." }); + if (shouldStubEnv) { + formatted["process.env"] = '"MISSING_ENV_VAR"'; + } + + return formatted; + } + + #shouldStub({ + target: targetInput, + prefix, + }: { + target: string | string[] | undefined; + prefix: string; + }): boolean { + const targets: string[] = Array.isArray(targetInput) ? targetInput : [targetInput || ""]; + + return targets.every( + (target) => + prefix === "process.env." && + this.#options.ignoreStub !== true && + (this.#options.ignoreStub === false || + (!target.includes("node") && !isMainThreadElectron(target))), + ); + } + + #loadFile(filePath: string, compilation?: any): Buffer | string { + try { + const fileContent = this.#inputFileSystem.readFileSync(filePath); + compilation?.buildDependencies.add(filePath); + return fileContent; + } catch (err: any) { + compilation?.missingDependencies.add(filePath); + this.#logger.log(`Unable to upload ${filePath} file due:\n ${err.toString()}`); + return "{}"; + } + } +} + +module.exports = Dotenv; diff --git a/packages/webpack-cli/src/types.ts b/packages/webpack-cli/src/types.ts index edbc901b7f3..931a1a39f9e 100644 --- a/packages/webpack-cli/src/types.ts +++ b/packages/webpack-cli/src/types.ts @@ -29,6 +29,7 @@ import { type stringifyStream } from "@discoveryjs/json-ext"; */ interface IWebpackCLI { + dotEnv: boolean; colors: WebpackCLIColors; logger: WebpackCLILogger; isColorSupportChanged: boolean | undefined; diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 7106410c6a7..51bfadc3b3b 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -87,13 +87,14 @@ class WebpackCLI implements IWebpackCLI { colors: WebpackCLIColors; logger: WebpackCLILogger; isColorSupportChanged: boolean | undefined; + dotEnv: boolean; builtInOptionsCache: WebpackCLIBuiltInOption[] | undefined; webpack!: typeof webpack; program: WebpackCLICommand; constructor() { this.colors = this.createColors(); this.logger = this.getLogger(); - + this.dotEnv = false; // Initialize program this.program = program; this.program.name("webpack"); @@ -1381,6 +1382,17 @@ class WebpackCLI implements IWebpackCLI { "Output the version number of 'webpack', 'webpack-cli' and 'webpack-dev-server' and commands.", ); + // webpack-cli add dot-env plugin + + this.program.option( + "--dot-env", + "Integrates dotenv configuration into your webpack configuration.", + ); + + this.program.on("option:dot-env", function () { + cli.dotEnv = true; + }); + // webpack-cli has it's own logic for showing suggestions this.program.showSuggestionAfterError(false); @@ -2359,6 +2371,12 @@ class WebpackCLI implements IWebpackCLI { isMultiCompiler: Array.isArray(config.options), }), ); + + // Add dotenv plugin to the config + if (this.dotEnv) { + const Dotenv = require("./plugins/dotenv-webpack-plugin"); + item.plugins.unshift(new Dotenv()); + } }; if (Array.isArray(config.options)) { diff --git a/test/api/capitalizeFirstLetter.test.js b/test/api/capitalizeFirstLetter.test.js old mode 100755 new mode 100644 diff --git a/test/api/generators/scaffold-utils.test.js b/test/api/generators/scaffold-utils.test.js old mode 100755 new mode 100644 diff --git a/test/dotenv/dotenv-empty.test.js b/test/dotenv/dotenv-empty.test.js new file mode 100644 index 00000000000..3328623a6f3 --- /dev/null +++ b/test/dotenv/dotenv-empty.test.js @@ -0,0 +1,50 @@ +"use strict"; + +const { run } = require("../utils/test-utils"); +const path = require("path"); +const fs = require("fs"); + +describe("dotenv", () => { + const testDirectory = path.join(__dirname, "test-dot-env"); + const outputFile = path.join(testDirectory, "output.js"); + + beforeAll(async () => { + if (!fs.existsSync(testDirectory)) { + fs.mkdirSync(testDirectory); + } + await fs.promises.writeFile( + path.join(testDirectory, "webpack.config.js"), + ` + const path = require('path'); + module.exports = { + entry: './index.js', + output: { + path: path.resolve(__dirname), + filename: 'output.js' + }, + }; + `, + ); + await fs.promises.writeFile( + path.join(testDirectory, "index.js"), + "module.exports = import.meta.env.TEST_VARIABLE;", + ); + await fs.promises.writeFile(path.join(testDirectory, ".env"), "TEST_VARIABLE="); + }); + + afterAll(() => { + fs.unlinkSync(path.join(testDirectory, "webpack.config.js")); + fs.unlinkSync(path.join(testDirectory, "index.js")); + fs.unlinkSync(path.join(testDirectory, ".env")); + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + fs.rmdirSync(testDirectory); + }); + + it("should refer to the example file", async () => { + await run(testDirectory, ["--dot-env"]); + const output = fs.readFileSync(outputFile, "utf-8"); + expect(output).toContain('exports=""'); + }); +}); diff --git a/test/dotenv/dotenv-expand.test.js b/test/dotenv/dotenv-expand.test.js new file mode 100644 index 00000000000..827b0ec4c63 --- /dev/null +++ b/test/dotenv/dotenv-expand.test.js @@ -0,0 +1,50 @@ +"use strict"; + +const { run } = require("../utils/test-utils"); +const path = require("path"); +const fs = require("fs"); + +describe("dotenv", () => { + const testDirectory = path.join(__dirname, "test-dot-env"); + const outputFile = path.join(testDirectory, "output.js"); + + beforeAll(async () => { + if (!fs.existsSync(testDirectory)) { + fs.mkdirSync(testDirectory); + } + await fs.promises.writeFile( + path.join(testDirectory, "webpack.config.js"), + ` + const path = require('path'); + module.exports = { + entry: './index.js', + output: { + path: path.resolve(__dirname), + filename: 'output.js' + }, + }; + `, + ); + await fs.promises.writeFile( + path.join(testDirectory, "index.js"), + "module.exports = import.meta.env.TEST_VARIABLE;", + ); + await fs.promises.writeFile(path.join(testDirectory, ".env"), "TEST_VARIABLE=\\$12345"); + }); + + afterAll(() => { + fs.unlinkSync(path.join(testDirectory, "webpack.config.js")); + fs.unlinkSync(path.join(testDirectory, "index.js")); + fs.unlinkSync(path.join(testDirectory, ".env")); + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + fs.rmdirSync(testDirectory); + }); + + it("should refer to the example file", async () => { + await run(testDirectory, ["--dot-env"]); + const output = fs.readFileSync(outputFile, "utf-8"); + expect(output).toContain("$12345"); + }); +}); diff --git a/test/dotenv/dotenv-safe.test.js b/test/dotenv/dotenv-safe.test.js new file mode 100644 index 00000000000..b603158c18a --- /dev/null +++ b/test/dotenv/dotenv-safe.test.js @@ -0,0 +1,55 @@ +"use strict"; + +const { run } = require("../utils/test-utils"); +const path = require("path"); +const fs = require("fs"); + +describe("dotenv", () => { + const testDirectory = path.join(__dirname, "test-dot-env"); + const outputFile = path.join(testDirectory, "output.js"); + + beforeAll(async () => { + if (!fs.existsSync(testDirectory)) { + fs.mkdirSync(testDirectory); + } + await fs.promises.writeFile( + path.join(testDirectory, "webpack.config.js"), + ` + const path = require('path'); + module.exports = { + entry: './index.js', + output: { + path: path.resolve(__dirname), + filename: 'output.js' + }, + }; + `, + ); + await fs.promises.writeFile( + path.join(testDirectory, "index.js"), + "module.exports = import.meta.env.TEST_VARIABLE;", + ); + await fs.promises.writeFile(path.join(testDirectory, ".env"), "TEST_VARIABLE=12345"); + await fs.promises.writeFile( + path.join(testDirectory, ".env.example"), + "TEST_VARIABLE=\nUN_DECLARED_VARIABLE=", + ); + }); + + afterAll(() => { + fs.unlinkSync(path.join(testDirectory, "webpack.config.js")); + fs.unlinkSync(path.join(testDirectory, "index.js")); + fs.unlinkSync(path.join(testDirectory, ".env")); + fs.unlinkSync(path.join(testDirectory, ".env.example")); + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + fs.rmdirSync(testDirectory); + }); + + it("should refer to the example file", async () => { + await expect(run(testDirectory, ["--dot-env"])).resolves.toThrow( + "Missing environment variable: UN_DECLARED_VARIABLE", + ); + }); +}); diff --git a/test/dotenv/dotenv.test.js b/test/dotenv/dotenv.test.js new file mode 100644 index 00000000000..11d83176b27 --- /dev/null +++ b/test/dotenv/dotenv.test.js @@ -0,0 +1,50 @@ +"use strict"; + +const { run } = require("../utils/test-utils"); +const path = require("path"); +const fs = require("fs"); + +describe("dotenv", () => { + const testDirectory = path.join(__dirname, "test-dot-env"); + const outputFile = path.join(testDirectory, "output.js"); + + beforeAll(async () => { + if (!fs.existsSync(testDirectory)) { + fs.mkdirSync(testDirectory); + } + await fs.promises.writeFile( + path.join(testDirectory, "webpack.config.js"), + ` + const path = require('path'); + module.exports = { + entry: './index.js', + output: { + path: path.resolve(__dirname), + filename: 'output.js' + }, + }; + `, + ); + await fs.promises.writeFile( + path.join(testDirectory, "index.js"), + "module.exports = import.meta.env.TEST_VARIABLE;", + ); + await fs.promises.writeFile(path.join(testDirectory, ".env"), "TEST_VARIABLE=12345"); + }); + + afterAll(() => { + fs.unlinkSync(path.join(testDirectory, "webpack.config.js")); + fs.unlinkSync(path.join(testDirectory, "index.js")); + fs.unlinkSync(path.join(testDirectory, ".env")); + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + fs.rmdirSync(testDirectory); + }); + + it("should find and replace values from .env when --dot-env arg passed", async () => { + await run(testDirectory, ["--dot-env"]); + const output = fs.readFileSync(outputFile, "utf-8"); + expect(output).toContain("12345"); + }); +}); diff --git a/test/watch/basic/src/entry.js b/test/watch/basic/src/entry.js index 923312d065f..1d8734ee1c8 100644 --- a/test/watch/basic/src/entry.js +++ b/test/watch/basic/src/entry.js @@ -1 +1 @@ -console.log("watch flag test"); +console.log('watch flag test'); diff --git a/yarn.lock b/yarn.lock index e91f60f9514..b70c1248951 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11763,4 +11763,4 @@ yn@3.1.1: yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== \ No newline at end of file