DotenvPlugin.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Natsu @xiaoxiaojx
  4. */
  5. "use strict";
  6. const FileSystemInfo = require("./FileSystemInfo");
  7. const { join } = require("./util/fs");
  8. /** @import { DotenvPluginOptions } from "../declarations/WebpackOptions" */
  9. /** @import Compiler from "./Compiler" */
  10. /** @import { ItemCacheFacade } from "./CacheFacade" */
  11. /** @import { InputFileSystem } from "./util/fs" */
  12. /** @import { Snapshot } from "./FileSystemInfo" */
  13. /** @typedef {Exclude<DotenvPluginOptions["prefix"], string | undefined>} Prefix */
  14. /** @typedef {Record<string, string>} Env */
  15. const DEFAULT_TEMPLATE = [
  16. ".env",
  17. ".env.local",
  18. ".env.[mode]",
  19. ".env.[mode].local"
  20. ];
  21. // cspell:ignore Motte, motdotla
  22. /*
  23. * `LINE`, `parse`, `_resolveEscapeSequences`, `expandValue` and `expand`
  24. * below are ported from dotenv v17.4.2 and dotenv-expand v13.0.0, both
  25. * BSD-2-Clause. Links stay version-pinned: later dotenv-expand releases
  26. * ship different license text.
  27. *
  28. * https://github.com/motdotla/dotenv/blob/v17.4.2/lib/main.js
  29. * https://github.com/motdotla/dotenv-expand/blob/v13.0.0/lib/main.js
  30. *
  31. * Copyright (c) 2015, Scott Motte
  32. * Copyright (c) 2016, Scott Motte
  33. * All rights reserved.
  34. *
  35. * Redistribution and use in source and binary forms, with or without
  36. * modification, are permitted provided that the following conditions are met:
  37. *
  38. * * Redistributions of source code must retain the above copyright notice, this
  39. * list of conditions and the following disclaimer.
  40. *
  41. * * Redistributions in binary form must reproduce the above copyright notice,
  42. * this list of conditions and the following disclaimer in the documentation
  43. * and/or other materials provided with the distribution.
  44. *
  45. * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  46. * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  47. * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  48. * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
  49. * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  50. * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
  51. * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
  52. * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
  53. * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  54. * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  55. */
  56. // Regex for parsing .env files
  57. // ported from https://github.com/motdotla/dotenv/blob/v17.4.2/lib/main.js#L38
  58. const LINE =
  59. /^\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$/gm;
  60. const PLUGIN_NAME = "DotenvPlugin";
  61. /**
  62. * Parse .env file content
  63. * ported from https://github.com/motdotla/dotenv/blob/v17.4.2/lib/main.js#L41
  64. * @param {string | Buffer} src the source content to parse
  65. * @returns {Env} parsed environment variables object
  66. */
  67. function parse(src) {
  68. const obj = /** @type {Env} */ (Object.create(null));
  69. // Convert buffer to string
  70. let lines = src.toString();
  71. // Convert line breaks to same format
  72. lines = lines.replace(/\r\n?/g, "\n");
  73. /** @type {null | RegExpExecArray} */
  74. let match;
  75. while ((match = LINE.exec(lines)) !== null) {
  76. const key = match[1];
  77. // Default undefined or null to empty string
  78. let value = match[2] || "";
  79. // Remove whitespace
  80. value = value.trim();
  81. // Check if double quoted
  82. const maybeQuote = value[0];
  83. // Remove surrounding quotes
  84. value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2");
  85. // Expand newlines if double quoted
  86. if (maybeQuote === '"') {
  87. value = value.replace(/\\n/g, "\n");
  88. value = value.replace(/\\r/g, "\r");
  89. }
  90. // Add to object
  91. obj[key] = value;
  92. }
  93. return obj;
  94. }
  95. /**
  96. * Resolve escape sequences
  97. * ported from https://github.com/motdotla/dotenv-expand/blob/v13.0.0/lib/main.js#L3
  98. * @param {string} value value to resolve
  99. * @returns {string} resolved value
  100. */
  101. function _resolveEscapeSequences(value) {
  102. return value.replace(/\\\$/g, "$");
  103. }
  104. /**
  105. * Expand environment variable value
  106. * ported from https://github.com/motdotla/dotenv-expand/blob/v13.0.0/lib/main.js#L7
  107. * @param {string} value value to expand
  108. * @param {Record<string, string | undefined>} processEnv process.env object
  109. * @param {Env} runningParsed running parsed object
  110. * @returns {string} expanded value
  111. */
  112. function expandValue(value, processEnv, runningParsed) {
  113. const env = { ...runningParsed, ...processEnv }; // process.env wins
  114. const regex = /(?<!\\)\$\{([^{}]+)\}|(?<!\\)\$([a-z_]\w*)/gi;
  115. let result = value;
  116. /** @type {null | RegExpExecArray} */
  117. let match;
  118. /** @type {Set<string>} */
  119. const seen = new Set(); // self-referential checker
  120. while ((match = regex.exec(result)) !== null) {
  121. seen.add(result);
  122. const [template, bracedExpression, unbracedExpression] = match;
  123. const expression = bracedExpression || unbracedExpression;
  124. // match the operators `:+`, `+`, `:-`, and `-`
  125. const opRegex = /(:\+|\+|:-|-)/;
  126. // find first match
  127. const opMatch = expression.match(opRegex);
  128. const splitter = opMatch ? opMatch[0] : null;
  129. const r = expression.split(/** @type {string} */ (splitter));
  130. // const r = splitter ? expression.split(splitter) : [expression];
  131. /** @type {string} */
  132. let defaultValue;
  133. /** @type {undefined | null | string} */
  134. let value;
  135. const key = r.shift();
  136. if ([":+", "+"].includes(splitter || "")) {
  137. defaultValue = env[key || ""] ? r.join(splitter || "") : "";
  138. value = null;
  139. } else {
  140. defaultValue = r.join(splitter || "");
  141. value = env[key || ""];
  142. }
  143. if (value) {
  144. // self-referential check
  145. result = seen.has(value)
  146. ? result.replace(template, defaultValue)
  147. : result.replace(template, value);
  148. } else {
  149. result = result.replace(template, defaultValue);
  150. }
  151. // if the result equaled what was in process.env and runningParsed then stop expanding
  152. if (result === runningParsed[key || ""]) {
  153. break;
  154. }
  155. regex.lastIndex = 0; // reset regex search position to re-evaluate after each replacement
  156. }
  157. return result;
  158. }
  159. /**
  160. * Expand environment variables in parsed object
  161. * ported from https://github.com/motdotla/dotenv-expand/blob/v13.0.0/lib/main.js#L65
  162. * @param {{ parsed: Env, processEnv: Record<string, string | undefined> }} options expand options
  163. * @returns {{ parsed: Env }} expanded options
  164. */
  165. function expand(options) {
  166. // for use with progressive expansion
  167. const runningParsed = /** @type {Env} */ (Object.create(null));
  168. const processEnv = options.processEnv;
  169. // dotenv.config() ran before this so the assumption is process.env has already been set
  170. for (const key in options.parsed) {
  171. let value = options.parsed[key];
  172. // short-circuit scenario: process.env was already set prior to the file value
  173. value =
  174. Object.prototype.hasOwnProperty.call(processEnv, key) &&
  175. processEnv[key] !== value
  176. ? /** @type {string} */ (processEnv[key])
  177. : expandValue(value, processEnv, runningParsed);
  178. const resolvedValue = _resolveEscapeSequences(value);
  179. options.parsed[key] = resolvedValue;
  180. // for use with progressive expansion
  181. runningParsed[key] = resolvedValue;
  182. }
  183. // Part of `dotenv-expand` code, but we don't need it because of we don't modify `process.env`
  184. // for (const processKey in options.parsed) {
  185. // if (processEnv) {
  186. // processEnv[processKey] = options.parsed[processKey];
  187. // }
  188. // }
  189. return options;
  190. }
  191. /**
  192. * Format environment variables as DefinePlugin definitions
  193. * @param {Env} env environment variables
  194. * @returns {Record<string, string>} formatted definitions
  195. */
  196. const envToDefinitions = (env) => {
  197. const definitions = /** @type {Record<string, string>} */ ({});
  198. for (const [key, value] of Object.entries(env)) {
  199. const defValue = JSON.stringify(value);
  200. definitions[`process.env.${key}`] = defValue;
  201. definitions[`import.meta.env.${key}`] = defValue;
  202. }
  203. return definitions;
  204. };
  205. class DotenvPlugin {
  206. /**
  207. * Creates an instance of DotenvPlugin.
  208. * @param {DotenvPluginOptions=} options options object
  209. */
  210. constructor(options = {}) {
  211. /** @type {DotenvPluginOptions} */
  212. this.options = options;
  213. }
  214. /**
  215. * Applies the plugin by registering its hooks on the compiler.
  216. * @param {Compiler} compiler the compiler instance
  217. * @returns {void}
  218. */
  219. apply(compiler) {
  220. compiler.hooks.validate.tap(PLUGIN_NAME, () => {
  221. compiler.validate(
  222. () => {
  223. const { definitions } = require("../schemas/WebpackOptions.json");
  224. return {
  225. definitions,
  226. oneOf: [{ $ref: "#/definitions/DotenvPluginOptions" }]
  227. };
  228. },
  229. this.options,
  230. {
  231. name: "Dotenv Plugin",
  232. baseDataPath: "options"
  233. }
  234. );
  235. });
  236. const definePlugin = new compiler.webpack.DefinePlugin({});
  237. const prefixes = Array.isArray(this.options.prefix)
  238. ? this.options.prefix
  239. : [this.options.prefix || "WEBPACK_"];
  240. /** @type {string | false} */
  241. const dir =
  242. typeof this.options.dir === "string"
  243. ? this.options.dir
  244. : typeof this.options.dir === "undefined"
  245. ? compiler.context
  246. : this.options.dir;
  247. /** @type {undefined | InstanceType<Snapshot>} */
  248. let snapshot;
  249. const cache = compiler.getCache(PLUGIN_NAME);
  250. const identifier = JSON.stringify(
  251. this.options.template || DEFAULT_TEMPLATE
  252. );
  253. const itemCache = cache.getItemCache(identifier, null);
  254. compiler.hooks.beforeCompile.tapPromise(PLUGIN_NAME, async () => {
  255. const { parsed, snapshot: newSnapshot } = dir
  256. ? await this._loadEnv(compiler, itemCache, dir)
  257. : { parsed: {} };
  258. const env = this._getEnv(prefixes, parsed);
  259. definePlugin.definitions = envToDefinitions(env || {});
  260. snapshot = newSnapshot;
  261. });
  262. compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
  263. if (snapshot) {
  264. compilation.fileDependencies.addAll(snapshot.getFileIterable());
  265. compilation.missingDependencies.addAll(snapshot.getMissingIterable());
  266. }
  267. });
  268. definePlugin.apply(compiler);
  269. }
  270. /**
  271. * Get list of env files to load based on mode and template
  272. * Similar to Vite's getEnvFilesForMode
  273. * @private
  274. * @param {InputFileSystem} inputFileSystem the input file system
  275. * @param {string | false} dir the directory containing .env files
  276. * @param {string | undefined} mode the mode (e.g., 'production', 'development')
  277. * @returns {string[]} array of file paths to load
  278. */
  279. _getEnvFilesForMode(inputFileSystem, dir, mode) {
  280. if (!dir) {
  281. return [];
  282. }
  283. const templates = this.options.template || DEFAULT_TEMPLATE;
  284. return templates
  285. .map((pattern) => pattern.replace(/\[mode\]/g, mode || "development"))
  286. .map((file) => join(inputFileSystem, dir, file));
  287. }
  288. /**
  289. * Get parsed env variables from `.env` files
  290. * @private
  291. * @param {InputFileSystem} fs input file system
  292. * @param {string} dir dir to load `.env` files
  293. * @param {string} mode mode
  294. * @returns {Promise<{ parsed: Env, fileDependencies: string[], missingDependencies: string[] }>} parsed env variables and dependencies
  295. */
  296. async _getParsed(fs, dir, mode) {
  297. /** @type {string[]} */
  298. const fileDependencies = [];
  299. /** @type {string[]} */
  300. const missingDependencies = [];
  301. // Get env files to load
  302. const envFiles = this._getEnvFilesForMode(fs, dir, mode);
  303. // Read all files
  304. const contents = await Promise.all(
  305. envFiles.map((filePath) =>
  306. this._loadFile(fs, filePath).then(
  307. (content) => {
  308. fileDependencies.push(filePath);
  309. return content;
  310. },
  311. () => {
  312. // File doesn't exist, add to missingDependencies (this is normal)
  313. missingDependencies.push(filePath);
  314. return "";
  315. }
  316. )
  317. )
  318. );
  319. // Parse all files and merge (later files override earlier ones)
  320. // Similar to Vite's implementation
  321. const parsed = /** @type {Env} */ (Object.create(null));
  322. for (const content of contents) {
  323. if (!content) continue;
  324. const entries = parse(content);
  325. for (const key in entries) {
  326. parsed[key] = entries[key];
  327. }
  328. }
  329. return { parsed, fileDependencies, missingDependencies };
  330. }
  331. /**
  332. * Loads the provided compiler.
  333. * @private
  334. * @param {Compiler} compiler compiler
  335. * @param {InstanceType<ItemCacheFacade>} itemCache item cache facade
  336. * @param {string} dir directory to read
  337. * @returns {Promise<{ parsed: Env, snapshot: InstanceType<Snapshot> }>} parsed result and snapshot
  338. */
  339. async _loadEnv(compiler, itemCache, dir) {
  340. const fs = /** @type {InputFileSystem} */ (compiler.inputFileSystem);
  341. const fileSystemInfo = new FileSystemInfo(fs, {
  342. unmanagedPaths: compiler.unmanagedPaths,
  343. managedPaths: compiler.managedPaths,
  344. immutablePaths: compiler.immutablePaths,
  345. hashFunction: compiler.options.output.hashFunction
  346. });
  347. const result = await itemCache.getPromise();
  348. if (result) {
  349. const isSnapshotValid = await new Promise((resolve, reject) => {
  350. fileSystemInfo.checkSnapshotValid(result.snapshot, (error, isValid) => {
  351. if (error) {
  352. reject(error);
  353. return;
  354. }
  355. resolve(isValid);
  356. });
  357. });
  358. if (isSnapshotValid) {
  359. return { parsed: result.parsed, snapshot: result.snapshot };
  360. }
  361. }
  362. const { parsed, fileDependencies, missingDependencies } =
  363. await this._getParsed(
  364. fs,
  365. dir,
  366. /** @type {string} */
  367. (compiler.options.mode)
  368. );
  369. const startTime = Date.now();
  370. const newSnapshot = await new Promise((resolve, reject) => {
  371. fileSystemInfo.createSnapshot(
  372. startTime,
  373. fileDependencies,
  374. null,
  375. missingDependencies,
  376. // `.env` files are build dependencies
  377. compiler.options.snapshot.buildDependencies,
  378. (err, snapshot) => {
  379. if (err) return reject(err);
  380. resolve(snapshot);
  381. }
  382. );
  383. });
  384. await itemCache.storePromise({ parsed, snapshot: newSnapshot });
  385. return { parsed, snapshot: newSnapshot };
  386. }
  387. /**
  388. * Generate env variables
  389. * @private
  390. * @param {Prefix} prefixes expose only environment variables that start with these prefixes
  391. * @param {Env} parsed parsed env variables
  392. * @returns {Env} env variables
  393. */
  394. _getEnv(prefixes, parsed) {
  395. // Always expand environment variables (like Vite does)
  396. // Make a copy of process.env so that dotenv-expand doesn't modify global process.env
  397. const processEnv = { ...process.env };
  398. expand({ parsed, processEnv });
  399. const env = /** @type {Env} */ (Object.create(null));
  400. // Get all keys from parser and process.env
  401. const keys = [...Object.keys(parsed), ...Object.keys(process.env)];
  402. // Prioritize actual env variables from `process.env`, fallback to parsed
  403. for (const key of keys) {
  404. if (prefixes.some((prefix) => key.startsWith(prefix))) {
  405. env[key] =
  406. Object.prototype.hasOwnProperty.call(process.env, key) &&
  407. process.env[key]
  408. ? process.env[key]
  409. : parsed[key];
  410. }
  411. }
  412. return env;
  413. }
  414. /**
  415. * Load a file with proper path resolution
  416. * @private
  417. * @param {InputFileSystem} fs the input file system
  418. * @param {string} file the file to load
  419. * @returns {Promise<string>} the content of the file
  420. */
  421. _loadFile(fs, file) {
  422. return new Promise((resolve, reject) => {
  423. fs.readFile(file, (err, content) => {
  424. if (err) reject(err);
  425. else resolve(/** @type {Buffer} */ (content).toString() || "");
  426. });
  427. });
  428. }
  429. }
  430. module.exports = DotenvPlugin;