webpack/lib/DotenvPlugin.js

428 lines
12 KiB
JavaScript
Raw Normal View History

2025-09-13 15:01:49 +08:00
/*
MIT License http://www.opensource.org/licenses/mit-license.php
Author Natsu @xiaoxiaojx
*/
"use strict";
const createSchemaValidation = require("./util/create-schema-validation");
2025-10-03 17:15:14 +08:00
const { join } = require("./util/fs");
2025-09-13 15:01:49 +08:00
2025-10-03 17:15:14 +08:00
/** @typedef {import("../declarations/WebpackOptions").DotenvPluginOptions} DotenvPluginOptions */
2025-09-13 15:01:49 +08:00
/** @typedef {import("./Compiler")} Compiler */
/** @typedef {import("./util/fs").InputFileSystem} InputFileSystem */
2025-10-03 21:23:49 +08:00
/** @type {DotenvPluginOptions} */
2025-09-13 15:01:49 +08:00
const DEFAULT_OPTIONS = {
2025-10-03 21:23:49 +08:00
prefix: "WEBPACK_",
dir: true
2025-09-13 15:01:49 +08:00
};
2025-10-03 17:15:14 +08:00
// Regex for parsing .env files
// ported from https://github.com/motdotla/dotenv/blob/master/lib/main.js#L32
2025-09-13 15:01:49 +08:00
const LINE =
/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
const PLUGIN_NAME = "DotenvPlugin";
const validate = createSchemaValidation(
2025-10-03 17:15:14 +08:00
undefined,
() => {
const { definitions } = require("../schemas/WebpackOptions.json");
return {
definitions,
oneOf: [{ $ref: "#/definitions/DotenvPluginOptions" }]
};
},
2025-09-13 15:01:49 +08:00
{
name: "Dotenv Plugin",
baseDataPath: "options"
}
);
/**
2025-10-03 17:15:14 +08:00
* Parse .env file content
2025-09-13 15:01:49 +08:00
* ported from https://github.com/motdotla/dotenv/blob/master/lib/main.js#L49
* @param {string|Buffer} src the source content to parse
* @returns {Record<string, string>} parsed environment variables object
*/
function parse(src) {
const obj = /** @type {Record<string, string>} */ ({});
// Convert buffer to string
let lines = src.toString();
// Convert line breaks to same format
lines = lines.replace(/\r\n?/gm, "\n");
let match;
while ((match = LINE.exec(lines)) !== null) {
const key = match[1];
// Default undefined or null to empty string
let value = match[2] || "";
// Remove whitespace
value = value.trim();
// Check if double quoted
const maybeQuote = value[0];
// Remove surrounding quotes
value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2");
// Expand newlines if double quoted
if (maybeQuote === '"') {
value = value.replace(/\\n/g, "\n");
value = value.replace(/\\r/g, "\r");
}
// Add to object
obj[key] = value;
}
return obj;
}
/**
2025-10-03 17:15:14 +08:00
* Resolve escape sequences
* ported from https://github.com/motdotla/dotenv-expand
* @param {string} value value to resolve
* @returns {string} resolved value
*/
function _resolveEscapeSequences(value) {
return value.replace(/\\\$/g, "$");
}
/**
* Expand environment variable value
* ported from https://github.com/motdotla/dotenv-expand
* @param {string} value value to expand
* @param {Record<string, string | undefined>} processEnv process.env object
* @param {Record<string, string>} runningParsed running parsed object
* @returns {string} expanded value
*/
function expandValue(value, processEnv, runningParsed) {
const env = { ...runningParsed, ...processEnv }; // process.env wins
const regex = /(?<!\\)\$\{([^{}]+)\}|(?<!\\)\$([A-Za-z_][A-Za-z0-9_]*)/g;
let result = value;
let match;
const seen = new Set(); // self-referential checker
while ((match = regex.exec(result)) !== null) {
seen.add(result);
const [template, bracedExpression, unbracedExpression] = match;
const expression = bracedExpression || unbracedExpression;
// match the operators `:+`, `+`, `:-`, and `-`
const opRegex = /(:\+|\+|:-|-)/;
// find first match
const opMatch = expression.match(opRegex);
const splitter = opMatch ? opMatch[0] : null;
2025-10-03 20:15:50 +08:00
const r = expression.split(/** @type {string} */ (splitter));
2025-10-03 17:15:14 +08:00
// const r = splitter ? expression.split(splitter) : [expression];
let defaultValue;
let value;
const key = r.shift();
if ([":+", "+"].includes(splitter || "")) {
defaultValue = env[key || ""] ? r.join(splitter || "") : "";
value = null;
} else {
defaultValue = r.join(splitter || "");
value = env[key || ""];
}
if (value) {
// self-referential check
result = seen.has(value)
? result.replace(template, defaultValue)
: result.replace(template, value);
} else {
result = result.replace(template, defaultValue);
}
// if the result equaled what was in process.env and runningParsed then stop expanding
if (result === runningParsed[key || ""]) {
break;
}
regex.lastIndex = 0; // reset regex search position to re-evaluate after each replacement
}
return result;
}
/**
* Expand environment variables in parsed object
* ported from https://github.com/motdotla/dotenv-expand
* @param {{ parsed: Record<string, string>, processEnv?: Record<string, string | undefined> }} options expand options
* @returns {{ parsed: Record<string, string> }} expanded options
*/
function expand(options) {
// for use with progressive expansion
const runningParsed = /** @type {Record<string, string>} */ ({});
let processEnv = process.env;
if (
options &&
options.processEnv !== null &&
options.processEnv !== undefined
) {
processEnv = options.processEnv;
}
// dotenv.config() ran before this so the assumption is process.env has already been set
for (const key in options.parsed) {
let value = options.parsed[key];
// short-circuit scenario: process.env was already set prior to the file value
value =
processEnv[key] && processEnv[key] !== value
? /** @type {string} */ (processEnv[key])
: expandValue(value, processEnv, runningParsed);
options.parsed[key] = _resolveEscapeSequences(value);
// for use with progressive expansion
runningParsed[key] = _resolveEscapeSequences(value);
}
for (const processKey in options.parsed) {
if (processEnv) {
processEnv[processKey] = options.parsed[processKey];
}
}
return options;
}
/**
* Resolve and validate env prefixes
* Similar to Vite's resolveEnvPrefix
2025-10-03 21:23:49 +08:00
* @param {string | string[] | undefined} rawPrefix raw prefix option
2025-10-03 17:15:14 +08:00
* @returns {string[]} normalized prefixes array
*/
2025-10-03 21:23:49 +08:00
const resolveEnvPrefix = (rawPrefix) => {
const prefixes = Array.isArray(rawPrefix)
? rawPrefix
: [rawPrefix || "WEBPACK_"];
2025-10-03 17:15:14 +08:00
// Check for empty prefix (security issue like Vite does)
if (prefixes.includes("")) {
throw new Error(
2025-10-03 21:23:49 +08:00
"prefix option contains value '', which could lead to unexpected exposure of sensitive information."
2025-10-03 17:15:14 +08:00
);
}
return prefixes;
};
/**
* Get list of env files to load based on mode
* Similar to Vite's getEnvFilesForMode
* @param {InputFileSystem} inputFileSystem the input file system
2025-10-03 21:23:49 +08:00
* @param {string} dir the directory containing .env files
2025-10-03 17:15:14 +08:00
* @param {string | undefined} mode the mode (e.g., 'production', 'development')
* @returns {string[]} array of file paths to load
*/
2025-10-03 21:23:49 +08:00
const getEnvFilesForMode = (inputFileSystem, dir, mode) => {
if (dir) {
2025-10-03 17:15:14 +08:00
return [
/** default file */ ".env",
/** local file */ ".env.local",
/** mode file */ `.env.${mode}`,
/** mode local file */ `.env.${mode}.local`
2025-10-03 21:23:49 +08:00
].map((file) => join(inputFileSystem, dir, file));
2025-10-03 17:15:14 +08:00
}
return [];
};
/**
* Format environment variables as DefinePlugin definitions
* @param {Record<string, string>} env environment variables
* @returns {Record<string, string>} formatted definitions
2025-09-13 15:01:49 +08:00
*/
2025-10-04 12:22:59 +08:00
const envToDefinitions = (env) => {
2025-10-03 17:15:14 +08:00
const definitions = /** @type {Record<string, string>} */ ({});
2025-09-13 15:01:49 +08:00
2025-10-03 17:15:14 +08:00
for (const [key, value] of Object.entries(env)) {
// Always use process.env. prefix for DefinePlugin
definitions[`process.env.${key}`] = JSON.stringify(value);
}
return definitions;
2025-09-13 15:01:49 +08:00
};
class DotenvPlugin {
/**
* @param {DotenvPluginOptions=} options options object
*/
constructor(options = {}) {
validate(options);
this.config = { ...DEFAULT_OPTIONS, ...options };
}
/**
* @param {Compiler} compiler the compiler instance
* @returns {void}
*/
apply(compiler) {
2025-09-23 01:08:53 +08:00
/** @type {string[] | undefined} */
let fileDependenciesCache;
2025-10-04 12:22:59 +08:00
const definePlugin = new compiler.webpack.DefinePlugin({});
definePlugin.apply(compiler);
2025-09-23 01:08:53 +08:00
2025-10-04 10:28:27 +08:00
compiler.hooks.beforeRun.tapAsync(PLUGIN_NAME, (_params, callback) => {
2025-09-13 15:01:49 +08:00
const inputFileSystem = /** @type {InputFileSystem} */ (
compiler.inputFileSystem
);
const context = compiler.context;
2025-10-03 17:15:14 +08:00
// Use webpack mode or fallback to NODE_ENV
const mode = /** @type {string | undefined} */ (
compiler.options.mode || process.env.NODE_ENV
);
2025-09-13 15:01:49 +08:00
2025-10-03 17:15:14 +08:00
this.loadEnv(
2025-09-23 00:56:26 +08:00
inputFileSystem,
2025-10-03 17:15:14 +08:00
mode,
2025-09-23 00:56:26 +08:00
context,
2025-10-03 17:15:14 +08:00
(err, env, fileDependencies) => {
2025-09-23 00:56:26 +08:00
if (err) return callback(err);
2025-10-03 17:15:14 +08:00
2025-10-04 12:22:59 +08:00
const definitions = envToDefinitions(env || {});
definePlugin.definitions = definitions;
2025-09-23 01:08:53 +08:00
fileDependenciesCache = fileDependencies;
2025-09-23 00:56:26 +08:00
callback();
}
);
2025-09-13 15:01:49 +08:00
});
2025-09-23 01:08:53 +08:00
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.fileDependencies.addAll(fileDependenciesCache || []);
});
2025-09-13 15:01:49 +08:00
}
/**
2025-10-03 17:15:14 +08:00
* Load environment variables from .env files
* Similar to Vite's loadEnv implementation
* @param {InputFileSystem} fs the input file system
* @param {string | undefined} mode the mode
2025-09-13 15:01:49 +08:00
* @param {string} context the compiler context
2025-10-03 17:15:14 +08:00
* @param {(err: Error | null, env?: Record<string, string>, fileDependencies?: string[]) => void} callback callback function
2025-09-13 15:01:49 +08:00
* @returns {void}
*/
2025-10-03 17:15:14 +08:00
loadEnv(fs, mode, context, callback) {
2025-10-03 21:23:49 +08:00
const { dir: rawDir, prefix: rawPrefix } =
2025-10-03 17:15:14 +08:00
/** @type {DotenvPluginOptions} */ (this.config);
let prefixes;
try {
2025-10-03 21:23:49 +08:00
prefixes = resolveEnvPrefix(rawPrefix);
2025-10-03 17:15:14 +08:00
} catch (err) {
return callback(/** @type {Error} */ (err));
}
2025-09-13 15:01:49 +08:00
2025-10-03 21:23:49 +08:00
const getDir = () => {
if (typeof rawDir === "string") {
return join(fs, context, rawDir);
2025-09-13 15:01:49 +08:00
}
2025-10-03 21:23:49 +08:00
if (rawDir === true) {
2025-10-03 17:15:14 +08:00
return context;
}
2025-10-03 21:23:49 +08:00
if (rawDir === false) {
return "";
}
2025-10-03 17:15:14 +08:00
return "";
};
2025-09-13 15:01:49 +08:00
2025-10-03 21:23:49 +08:00
const dir = getDir();
2025-09-13 15:01:49 +08:00
2025-10-03 17:15:14 +08:00
// Get env files to load
2025-10-03 21:23:49 +08:00
const envFiles = getEnvFilesForMode(fs, dir, mode);
2025-10-03 17:15:14 +08:00
/** @type {string[]} */
const fileDependencies = [];
// Read all files
const readPromises = envFiles.map((filePath) =>
this.loadFile(fs, filePath).then(
(content) => {
fileDependencies.push(filePath);
return { content, filePath };
},
() =>
// File doesn't exist, skip it (this is normal)
({ content: "", filePath })
)
);
2025-09-13 15:01:49 +08:00
2025-10-03 17:15:14 +08:00
Promise.all(readPromises)
.then((results) => {
// Parse all files and merge (later files override earlier ones)
// Similar to Vite's implementation
const parsed = /** @type {Record<string, string>} */ ({});
for (const { content } of results) {
if (!content) continue;
const entries = parse(content);
for (const key in entries) {
parsed[key] = entries[key];
2025-09-13 15:01:49 +08:00
}
}
2025-10-03 17:15:14 +08:00
// Always expand environment variables (like Vite does)
// Make a copy of process.env so that dotenv-expand doesn't modify global process.env
const processEnv = { ...process.env };
expand({ parsed, processEnv });
2025-09-13 15:01:49 +08:00
2025-10-03 17:15:14 +08:00
// Filter by prefixes and prioritize process.env (like Vite)
const env = /** @type {Record<string, string>} */ ({});
2025-09-13 15:01:49 +08:00
2025-10-03 17:15:14 +08:00
// First, add filtered vars from parsed .env files
for (const [key, value] of Object.entries(parsed)) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = value;
}
2025-09-13 15:01:49 +08:00
}
2025-10-03 17:15:14 +08:00
// Then, prioritize actual env variables starting with prefixes
// These are typically provided inline and should be prioritized (like Vite)
for (const key in process.env) {
if (prefixes.some((prefix) => key.startsWith(prefix))) {
env[key] = /** @type {string} */ (process.env[key]);
}
2025-09-23 00:56:26 +08:00
}
2025-10-03 17:15:14 +08:00
callback(null, env, fileDependencies);
2025-09-23 00:56:26 +08:00
})
.catch((err) => {
callback(err);
});
2025-09-13 15:01:49 +08:00
}
/**
* Load a file with proper path resolution
2025-09-23 00:56:26 +08:00
* @param {InputFileSystem} fs the input file system
* @param {string} file the file to load
* @returns {Promise<string>} the content of the file
2025-09-13 15:01:49 +08:00
*/
2025-09-23 00:56:26 +08:00
loadFile(fs, file) {
return new Promise((resolve, reject) => {
fs.readFile(file, "utf8", (err, content) => {
if (err) reject(err);
2025-10-03 17:15:14 +08:00
else resolve(content || "");
2025-09-23 00:56:26 +08:00
});
2025-09-13 15:01:49 +08:00
});
}
}
module.exports = DotenvPlugin;