DotenvPlugin.js 13 KB

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