DotenvPlugin.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  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. /** @typedef {import("../declarations/WebpackOptions").DotenvPluginOptions} DotenvPluginOptions */
  9. /** @typedef {import("./Compiler")} Compiler */
  10. /** @typedef {import("./CacheFacade").ItemCacheFacade} ItemCacheFacade */
  11. /** @typedef {import("./util/fs").InputFileSystem} InputFileSystem */
  12. /** @typedef {import("./FileSystemInfo").Snapshot} Snapshot */
  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. // Regex for parsing .env files
  22. // ported from https://github.com/motdotla/dotenv/blob/master/lib/main.js#L49
  23. const LINE =
  24. /^\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$/gm;
  25. const PLUGIN_NAME = "DotenvPlugin";
  26. /**
  27. * Parse .env file content
  28. * ported from https://github.com/motdotla/dotenv/blob/master/lib/main.js#L49
  29. * @param {string | Buffer} src the source content to parse
  30. * @returns {Env} parsed environment variables object
  31. */
  32. function parse(src) {
  33. const obj = /** @type {Env} */ (Object.create(null));
  34. // Convert buffer to string
  35. let lines = src.toString();
  36. // Convert line breaks to same format
  37. lines = lines.replace(/\r\n?/g, "\n");
  38. /** @type {null | RegExpExecArray} */
  39. let match;
  40. while ((match = LINE.exec(lines)) !== null) {
  41. const key = match[1];
  42. // Default undefined or null to empty string
  43. let value = match[2] || "";
  44. // Remove whitespace
  45. value = value.trim();
  46. // Check if double quoted
  47. const maybeQuote = value[0];
  48. // Remove surrounding quotes
  49. value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2");
  50. // Expand newlines if double quoted
  51. if (maybeQuote === '"') {
  52. value = value.replace(/\\n/g, "\n");
  53. value = value.replace(/\\r/g, "\r");
  54. }
  55. // Add to object
  56. obj[key] = value;
  57. }
  58. return obj;
  59. }
  60. /**
  61. * Resolve escape sequences
  62. * ported from https://github.com/motdotla/dotenv-expand
  63. * @param {string} value value to resolve
  64. * @returns {string} resolved value
  65. */
  66. function _resolveEscapeSequences(value) {
  67. return value.replace(/\\\$/g, "$");
  68. }
  69. /**
  70. * Expand environment variable value
  71. * ported from https://github.com/motdotla/dotenv-expand
  72. * @param {string} value value to expand
  73. * @param {Record<string, string | undefined>} processEnv process.env object
  74. * @param {Env} runningParsed running parsed object
  75. * @returns {string} expanded value
  76. */
  77. function expandValue(value, processEnv, runningParsed) {
  78. const env = { ...runningParsed, ...processEnv }; // process.env wins
  79. const regex = /(?<!\\)\$\{([^{}]+)\}|(?<!\\)\$([a-z_]\w*)/gi;
  80. let result = value;
  81. /** @type {null | RegExpExecArray} */
  82. let match;
  83. /** @type {Set<string>} */
  84. const seen = new Set(); // self-referential checker
  85. while ((match = regex.exec(result)) !== null) {
  86. seen.add(result);
  87. const [template, bracedExpression, unbracedExpression] = match;
  88. const expression = bracedExpression || unbracedExpression;
  89. // match the operators `:+`, `+`, `:-`, and `-`
  90. const opRegex = /(:\+|\+|:-|-)/;
  91. // find first match
  92. const opMatch = expression.match(opRegex);
  93. const splitter = opMatch ? opMatch[0] : null;
  94. const r = expression.split(/** @type {string} */ (splitter));
  95. // const r = splitter ? expression.split(splitter) : [expression];
  96. /** @type {string} */
  97. let defaultValue;
  98. /** @type {undefined | null | string} */
  99. let value;
  100. const key = r.shift();
  101. if ([":+", "+"].includes(splitter || "")) {
  102. defaultValue = env[key || ""] ? r.join(splitter || "") : "";
  103. value = null;
  104. } else {
  105. defaultValue = r.join(splitter || "");
  106. value = env[key || ""];
  107. }
  108. if (value) {
  109. // self-referential check
  110. result = seen.has(value)
  111. ? result.replace(template, defaultValue)
  112. : result.replace(template, value);
  113. } else {
  114. result = result.replace(template, defaultValue);
  115. }
  116. // if the result equaled what was in process.env and runningParsed then stop expanding
  117. if (result === runningParsed[key || ""]) {
  118. break;
  119. }
  120. regex.lastIndex = 0; // reset regex search position to re-evaluate after each replacement
  121. }
  122. return result;
  123. }
  124. /**
  125. * Expand environment variables in parsed object
  126. * ported from https://github.com/motdotla/dotenv-expand
  127. * @param {{ parsed: Env, processEnv: Record<string, string | undefined> }} options expand options
  128. * @returns {{ parsed: Env }} expanded options
  129. */
  130. function expand(options) {
  131. // for use with progressive expansion
  132. const runningParsed = /** @type {Env} */ (Object.create(null));
  133. const processEnv = options.processEnv;
  134. // dotenv.config() ran before this so the assumption is process.env has already been set
  135. for (const key in options.parsed) {
  136. let value = options.parsed[key];
  137. // short-circuit scenario: process.env was already set prior to the file value
  138. value =
  139. Object.prototype.hasOwnProperty.call(processEnv, key) &&
  140. processEnv[key] !== value
  141. ? /** @type {string} */ (processEnv[key])
  142. : expandValue(value, processEnv, runningParsed);
  143. const resolvedValue = _resolveEscapeSequences(value);
  144. options.parsed[key] = resolvedValue;
  145. // for use with progressive expansion
  146. runningParsed[key] = resolvedValue;
  147. }
  148. // Part of `dotenv-expand` code, but we don't need it because of we don't modify `process.env`
  149. // for (const processKey in options.parsed) {
  150. // if (processEnv) {
  151. // processEnv[processKey] = options.parsed[processKey];
  152. // }
  153. // }
  154. return options;
  155. }
  156. /**
  157. * Format environment variables as DefinePlugin definitions
  158. * @param {Env} env environment variables
  159. * @returns {Record<string, string>} formatted definitions
  160. */
  161. const envToDefinitions = (env) => {
  162. const definitions = /** @type {Record<string, string>} */ ({});
  163. for (const [key, value] of Object.entries(env)) {
  164. const defValue = JSON.stringify(value);
  165. definitions[`process.env.${key}`] = defValue;
  166. definitions[`import.meta.env.${key}`] = defValue;
  167. }
  168. return definitions;
  169. };
  170. class DotenvPlugin {
  171. /**
  172. * Creates an instance of DotenvPlugin.
  173. * @param {DotenvPluginOptions=} options options object
  174. */
  175. constructor(options = {}) {
  176. /** @type {DotenvPluginOptions} */
  177. this.options = options;
  178. }
  179. /**
  180. * Applies the plugin by registering its hooks on the compiler.
  181. * @param {Compiler} compiler the compiler instance
  182. * @returns {void}
  183. */
  184. apply(compiler) {
  185. compiler.hooks.validate.tap(PLUGIN_NAME, () => {
  186. compiler.validate(
  187. () => {
  188. const { definitions } = require("../schemas/WebpackOptions.json");
  189. return {
  190. definitions,
  191. oneOf: [{ $ref: "#/definitions/DotenvPluginOptions" }]
  192. };
  193. },
  194. this.options,
  195. {
  196. name: "Dotenv Plugin",
  197. baseDataPath: "options"
  198. }
  199. );
  200. });
  201. const definePlugin = new compiler.webpack.DefinePlugin({});
  202. const prefixes = Array.isArray(this.options.prefix)
  203. ? this.options.prefix
  204. : [this.options.prefix || "WEBPACK_"];
  205. /** @type {string | false} */
  206. const dir =
  207. typeof this.options.dir === "string"
  208. ? this.options.dir
  209. : typeof this.options.dir === "undefined"
  210. ? compiler.context
  211. : this.options.dir;
  212. /** @type {undefined | Snapshot} */
  213. let snapshot;
  214. const cache = compiler.getCache(PLUGIN_NAME);
  215. const identifier = JSON.stringify(
  216. this.options.template || DEFAULT_TEMPLATE
  217. );
  218. const itemCache = cache.getItemCache(identifier, null);
  219. compiler.hooks.beforeCompile.tapPromise(PLUGIN_NAME, async () => {
  220. const { parsed, snapshot: newSnapshot } = dir
  221. ? await this._loadEnv(compiler, itemCache, dir)
  222. : { parsed: {} };
  223. const env = this._getEnv(prefixes, parsed);
  224. definePlugin.definitions = envToDefinitions(env || {});
  225. snapshot = newSnapshot;
  226. });
  227. compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
  228. if (snapshot) {
  229. compilation.fileDependencies.addAll(snapshot.getFileIterable());
  230. compilation.missingDependencies.addAll(snapshot.getMissingIterable());
  231. }
  232. });
  233. definePlugin.apply(compiler);
  234. }
  235. /**
  236. * Get list of env files to load based on mode and template
  237. * Similar to Vite's getEnvFilesForMode
  238. * @private
  239. * @param {InputFileSystem} inputFileSystem the input file system
  240. * @param {string | false} dir the directory containing .env files
  241. * @param {string | undefined} mode the mode (e.g., 'production', 'development')
  242. * @returns {string[]} array of file paths to load
  243. */
  244. _getEnvFilesForMode(inputFileSystem, dir, mode) {
  245. if (!dir) {
  246. return [];
  247. }
  248. const templates = this.options.template || DEFAULT_TEMPLATE;
  249. return templates
  250. .map((pattern) => pattern.replace(/\[mode\]/g, mode || "development"))
  251. .map((file) => join(inputFileSystem, dir, file));
  252. }
  253. /**
  254. * Get parsed env variables from `.env` files
  255. * @private
  256. * @param {InputFileSystem} fs input file system
  257. * @param {string} dir dir to load `.env` files
  258. * @param {string} mode mode
  259. * @returns {Promise<{ parsed: Env, fileDependencies: string[], missingDependencies: string[] }>} parsed env variables and dependencies
  260. */
  261. async _getParsed(fs, dir, mode) {
  262. /** @type {string[]} */
  263. const fileDependencies = [];
  264. /** @type {string[]} */
  265. const missingDependencies = [];
  266. // Get env files to load
  267. const envFiles = this._getEnvFilesForMode(fs, dir, mode);
  268. // Read all files
  269. const contents = await Promise.all(
  270. envFiles.map((filePath) =>
  271. this._loadFile(fs, filePath).then(
  272. (content) => {
  273. fileDependencies.push(filePath);
  274. return content;
  275. },
  276. () => {
  277. // File doesn't exist, add to missingDependencies (this is normal)
  278. missingDependencies.push(filePath);
  279. return "";
  280. }
  281. )
  282. )
  283. );
  284. // Parse all files and merge (later files override earlier ones)
  285. // Similar to Vite's implementation
  286. const parsed = /** @type {Env} */ (Object.create(null));
  287. for (const content of contents) {
  288. if (!content) continue;
  289. const entries = parse(content);
  290. for (const key in entries) {
  291. parsed[key] = entries[key];
  292. }
  293. }
  294. return { parsed, fileDependencies, missingDependencies };
  295. }
  296. /**
  297. * Loads the provided compiler.
  298. * @private
  299. * @param {Compiler} compiler compiler
  300. * @param {ItemCacheFacade} itemCache item cache facade
  301. * @param {string} dir directory to read
  302. * @returns {Promise<{ parsed: Env, snapshot: Snapshot }>} parsed result and snapshot
  303. */
  304. async _loadEnv(compiler, itemCache, dir) {
  305. const fs = /** @type {InputFileSystem} */ (compiler.inputFileSystem);
  306. const fileSystemInfo = new FileSystemInfo(fs, {
  307. unmanagedPaths: compiler.unmanagedPaths,
  308. managedPaths: compiler.managedPaths,
  309. immutablePaths: compiler.immutablePaths,
  310. hashFunction: compiler.options.output.hashFunction
  311. });
  312. const result = await itemCache.getPromise();
  313. if (result) {
  314. const isSnapshotValid = await new Promise((resolve, reject) => {
  315. fileSystemInfo.checkSnapshotValid(result.snapshot, (error, isValid) => {
  316. if (error) {
  317. reject(error);
  318. return;
  319. }
  320. resolve(isValid);
  321. });
  322. });
  323. if (isSnapshotValid) {
  324. return { parsed: result.parsed, snapshot: result.snapshot };
  325. }
  326. }
  327. const { parsed, fileDependencies, missingDependencies } =
  328. await this._getParsed(
  329. fs,
  330. dir,
  331. /** @type {string} */
  332. (compiler.options.mode)
  333. );
  334. const startTime = Date.now();
  335. const newSnapshot = await new Promise((resolve, reject) => {
  336. fileSystemInfo.createSnapshot(
  337. startTime,
  338. fileDependencies,
  339. null,
  340. missingDependencies,
  341. // `.env` files are build dependencies
  342. compiler.options.snapshot.buildDependencies,
  343. (err, snapshot) => {
  344. if (err) return reject(err);
  345. resolve(snapshot);
  346. }
  347. );
  348. });
  349. await itemCache.storePromise({ parsed, snapshot: newSnapshot });
  350. return { parsed, snapshot: newSnapshot };
  351. }
  352. /**
  353. * Generate env variables
  354. * @private
  355. * @param {Prefix} prefixes expose only environment variables that start with these prefixes
  356. * @param {Env} parsed parsed env variables
  357. * @returns {Env} env variables
  358. */
  359. _getEnv(prefixes, parsed) {
  360. // Always expand environment variables (like Vite does)
  361. // Make a copy of process.env so that dotenv-expand doesn't modify global process.env
  362. const processEnv = { ...process.env };
  363. expand({ parsed, processEnv });
  364. const env = /** @type {Env} */ (Object.create(null));
  365. // Get all keys from parser and process.env
  366. const keys = [...Object.keys(parsed), ...Object.keys(process.env)];
  367. // Prioritize actual env variables from `process.env`, fallback to parsed
  368. for (const key of keys) {
  369. if (prefixes.some((prefix) => key.startsWith(prefix))) {
  370. env[key] =
  371. Object.prototype.hasOwnProperty.call(process.env, key) &&
  372. process.env[key]
  373. ? process.env[key]
  374. : parsed[key];
  375. }
  376. }
  377. return env;
  378. }
  379. /**
  380. * Load a file with proper path resolution
  381. * @private
  382. * @param {InputFileSystem} fs the input file system
  383. * @param {string} file the file to load
  384. * @returns {Promise<string>} the content of the file
  385. */
  386. _loadFile(fs, file) {
  387. return new Promise((resolve, reject) => {
  388. fs.readFile(file, (err, content) => {
  389. if (err) reject(err);
  390. else resolve(/** @type {Buffer} */ (content).toString() || "");
  391. });
  392. });
  393. }
  394. }
  395. module.exports = DotenvPlugin;