DotenvPlugin.js 13 KB

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