feat: add DotenvPlugin

This commit is contained in:
xiaoxiaojx 2025-09-13 15:01:49 +08:00
parent 1f5e8e55c7
commit 573528e410
21 changed files with 728 additions and 1 deletions

View File

@ -317,7 +317,8 @@
"Kumar",
"spacek",
"thelarkinn",
"behaviour"
"behaviour",
"systemvars"
],
"ignoreRegExpList": [
"/Author.+/",

36
declarations/plugins/DotenvPlugin.d.ts vendored Normal file
View File

@ -0,0 +1,36 @@
/*
* This file was automatically generated.
* DO NOT MODIFY BY HAND.
* Run `yarn fix:special` to update
*/
export interface DotenvPluginOptions {
/**
* Whether to allow empty strings in safe mode. If false, will throw an error if any env variables are empty (but only if safe mode is enabled).
*/
allowEmptyValues?: boolean;
/**
* Adds support for dotenv-defaults. If set to true, uses ./.env.defaults. If a string, uses that location for a defaults file.
*/
defaults?: boolean | string;
/**
* Allows your variables to be "expanded" for reusability within your .env file.
*/
expand?: boolean;
/**
* The path to your environment variables. This same path applies to the .env.example and .env.defaults files.
*/
path?: string;
/**
* The prefix to use before the name of your env variables.
*/
prefix?: string;
/**
* If true, load '.env.example' to verify the '.env' variables are all set. Can also be a string to a different file.
*/
safe?: boolean | string;
/**
* Set to true if you would rather load all system variables as well (useful for CI purposes).
*/
systemvars?: boolean;
}

345
lib/DotenvPlugin.js Normal file
View File

@ -0,0 +1,345 @@
/*
MIT License http://www.opensource.org/licenses/mit-license.php
Author Natsu @xiaoxiaojx
*/
"use strict";
const createSchemaValidation = require("./util/create-schema-validation");
const { isAbsolute, join } = require("./util/fs");
/** @typedef {import("./Compiler")} Compiler */
/** @typedef {import("./util/fs").InputFileSystem} InputFileSystem */
/** @typedef {import("../declarations/plugins/DotenvPlugin").DotenvPluginOptions} DotenvPluginOptions */
const DEFAULT_PATH = "./.env";
const DEFAULT_OPTIONS = {
path: DEFAULT_PATH,
prefix: "process.env."
};
const LINE =
/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
const PLUGIN_NAME = "DotenvPlugin";
const validate = createSchemaValidation(
require("../schemas/plugins/DotenvPlugin.check"),
() => require("../schemas/plugins/DotenvPlugin.json"),
{
name: "Dotenv Plugin",
baseDataPath: "options"
}
);
/**
* @param {string} env environment variable value
* @param {Record<string, string>} vars variables object
* @returns {string} interpolated value
*/
const interpolate = (env, vars) => {
const matches = env.match(/\$([a-zA-Z0-9_]+)|\${([a-zA-Z0-9_]+)}/g) || [];
for (const match of matches) {
const key = match.replace(/\$|{|}/g, "");
let variable = vars[key] || "";
variable = interpolate(variable, vars);
env = env.replace(match, variable);
}
return env;
};
/**
* 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;
}
/**
* Parses objects like before, but with defaults!
* @param {string} src the original src
* @param {string=} defaultSrc the new-and-improved default source
* @returns {Record<string, string>} the parsed results
*/
const mergeParse = (src, defaultSrc = "") => {
const parsedSrc = parse(src);
const parsedDefault = parse(defaultSrc);
return { ...parsedDefault, ...parsedSrc };
};
// ported from https://github.com/mrsteele/dotenv-webpack
class DotenvPlugin {
/**
* @param {DotenvPluginOptions=} options options object
*/
constructor(options = {}) {
validate(options);
this.config = { ...DEFAULT_OPTIONS, ...options };
}
/**
* Apply the plugin
* @param {Compiler} compiler the compiler instance
* @returns {void}
*/
apply(compiler) {
compiler.hooks.beforeCompile.tapAsync(PLUGIN_NAME, (_params, callback) => {
const inputFileSystem = /** @type {InputFileSystem} */ (
compiler.inputFileSystem
);
const context = compiler.context;
this.gatherVariables(inputFileSystem, context, (err, variables) => {
if (err) return callback(err);
const definitions = this.formatDefinitions(variables || {});
const DefinePlugin = compiler.webpack.DefinePlugin;
new DefinePlugin(definitions).apply(compiler);
callback();
});
});
}
/**
* @param {InputFileSystem} inputFileSystem the input file system
* @param {string} context the compiler context
* @param {(err: Error | null, variables?: Record<string, string>) => void} callback callback function
* @returns {void}
*/
gatherVariables(inputFileSystem, context, callback) {
const { safe, allowEmptyValues } = /** @type {DotenvPluginOptions} */ (
this.config
);
const vars = /** @type {Record<string, string>} */ (this.initializeVars());
this.getEnvs(inputFileSystem, context, (err, result) => {
if (err) return callback(err);
if (!result) {
return callback(new Error("Failed to get environment variables"));
}
const { env, blueprint } = result;
try {
for (const key of Object.keys(blueprint)) {
const value = Object.prototype.hasOwnProperty.call(vars, key)
? vars[key]
: env[key];
const isMissing =
typeof value === "undefined" ||
value === null ||
(!allowEmptyValues && value === "");
if (safe && isMissing) {
throw new Error(`Missing environment variable: ${key}`);
} else {
vars[key] = value;
}
}
// add the leftovers
if (safe) {
for (const key of Object.keys(env)) {
if (!Object.prototype.hasOwnProperty.call(vars, key)) {
vars[key] = env[key];
}
}
}
callback(null, vars);
} catch (error) {
callback(error instanceof Error ? error : new Error(String(error)));
}
});
}
initializeVars() {
const config = /** @type {DotenvPluginOptions} */ (this.config);
if (config.systemvars) {
const vars = /** @type {Record<string, string>} */ ({});
for (const key in process.env) {
if (process.env[key] !== undefined) {
vars[key] = /** @type {string} */ (process.env[key]);
}
}
return vars;
}
return /** @type {Record<string, string>} */ ({});
}
/**
* @param {InputFileSystem} inputFileSystem the input file system
* @param {string} context the compiler context
* @param {(err: Error | null, result?: {env: Record<string, string>, blueprint: Record<string, string>}) => void} callback callback function
* @returns {void}
*/
getEnvs(inputFileSystem, context, callback) {
const { path, safe } = /** @type {DotenvPluginOptions} */ (this.config);
// First load the main env file and defaults
this.loadFile(
{
file: path || DEFAULT_PATH,
inputFileSystem,
context
},
(err, envContent) => {
if (err) return callback(err);
this.getDefaults(inputFileSystem, context, (err, defaultsContent) => {
if (err) return callback(err);
const env = mergeParse(envContent || "", defaultsContent || "");
let blueprint = env;
if (safe) {
let file = `${path || DEFAULT_PATH}.example`;
if (safe !== true) {
file = safe;
}
this.loadFile(
{
file,
inputFileSystem,
context
},
(err, blueprintContent) => {
if (err) return callback(err);
blueprint = mergeParse(blueprintContent || "");
callback(null, { env, blueprint });
}
);
} else {
callback(null, { env, blueprint });
}
});
}
);
}
/**
* @param {InputFileSystem} inputFileSystem the input file system
* @param {string} context the compiler context
* @param {(err: Error | null, content?: string) => void} callback callback function
* @returns {void}
*/
getDefaults(inputFileSystem, context, callback) {
const { path, defaults } = /** @type {DotenvPluginOptions} */ (this.config);
if (defaults) {
const defaultsFile =
defaults === true ? `${path || DEFAULT_PATH}.defaults` : defaults;
this.loadFile(
{
file: defaultsFile,
inputFileSystem,
context
},
callback
);
} else {
callback(null, "");
}
}
/**
* Load a file with proper path resolution
* @param {object} options options object
* @param {string} options.file the file to load
* @param {InputFileSystem} options.inputFileSystem the input file system
* @param {string} options.context the compiler context for resolving relative paths
* @param {(err: Error | null, content?: string) => void} callback callback function
* @returns {void}
*/
loadFile({ file, inputFileSystem, context }, callback) {
// Resolve relative paths based on compiler context
const resolvedPath = isAbsolute(file)
? file
: join(inputFileSystem, context, file);
inputFileSystem.readFile(resolvedPath, "utf8", (err, content) => {
if (err) {
// File doesn't exist, return empty string
callback(null, "");
} else {
callback(null, /** @type {string} */ (content));
}
});
}
/**
* @param {Record<string, string>} variables variables object
* @returns {Record<string, string>} formatted data
*/
formatDefinitions(variables) {
const { expand, prefix } = /** @type {DotenvPluginOptions} */ (this.config);
const formatted = Object.keys(variables).reduce((obj, key) => {
const v = variables[key];
const vKey = `${prefix}${key}`;
let vValue;
if (expand) {
if (v.slice(0, 2) === "\\$") {
vValue = v.slice(1);
} else if (v.indexOf("\\$") > 0) {
vValue = v.replace(/\\\$/g, "$");
} else {
vValue = interpolate(v, variables);
}
} else {
vValue = v;
}
obj[vKey] = JSON.stringify(vValue);
return obj;
}, /** @type {Record<string, string>} */ ({}));
return formatted;
}
}
module.exports = DotenvPlugin;

View File

@ -232,6 +232,9 @@ module.exports = mergeExports(fn, {
get DynamicEntryPlugin() {
return require("./DynamicEntryPlugin");
},
get DotenvPlugin() {
return require("./DotenvPlugin");
},
get EntryOptionPlugin() {
return require("./EntryOptionPlugin");
},

View File

@ -0,0 +1,7 @@
/*
* This file was automatically generated.
* DO NOT MODIFY BY HAND.
* Run `yarn fix:special` to update
*/
declare const check: (options: import("../../declarations/plugins/DotenvPlugin").DotenvPluginOptions) => boolean;
export = check;

View File

@ -0,0 +1,6 @@
/*
* This file was automatically generated.
* DO NOT MODIFY BY HAND.
* Run `yarn fix:special` to update
*/
"use strict";function e(r,{instancePath:t="",parentData:s,parentDataProperty:o,rootData:a=r}={}){let n=null,l=0;if(0===l){if(!r||"object"!=typeof r||Array.isArray(r))return e.errors=[{params:{type:"object"}}],!1;{const t=l;for(const t in r)if("allowEmptyValues"!==t&&"defaults"!==t&&"expand"!==t&&"path"!==t&&"prefix"!==t&&"safe"!==t&&"systemvars"!==t)return e.errors=[{params:{additionalProperty:t}}],!1;if(t===l){if(void 0!==r.allowEmptyValues){const t=l;if("boolean"!=typeof r.allowEmptyValues)return e.errors=[{params:{type:"boolean"}}],!1;var i=t===l}else i=!0;if(i){if(void 0!==r.defaults){let t=r.defaults;const s=l,o=l;let a=!1;const f=l;if("boolean"!=typeof t){const e={params:{type:"boolean"}};null===n?n=[e]:n.push(e),l++}var p=f===l;if(a=a||p,!a){const e=l;if(l===e)if("string"==typeof t){if(t.length<1){const e={params:{}};null===n?n=[e]:n.push(e),l++}}else{const e={params:{type:"string"}};null===n?n=[e]:n.push(e),l++}p=e===l,a=a||p}if(!a){const r={params:{}};return null===n?n=[r]:n.push(r),l++,e.errors=n,!1}l=o,null!==n&&(o?n.length=o:n=null),i=s===l}else i=!0;if(i){if(void 0!==r.expand){const t=l;if("boolean"!=typeof r.expand)return e.errors=[{params:{type:"boolean"}}],!1;i=t===l}else i=!0;if(i){if(void 0!==r.path){let t=r.path;const s=l;if(l===s){if("string"!=typeof t)return e.errors=[{params:{type:"string"}}],!1;if(t.length<1)return e.errors=[{params:{}}],!1}i=s===l}else i=!0;if(i){if(void 0!==r.prefix){let t=r.prefix;const s=l;if(l===s){if("string"!=typeof t)return e.errors=[{params:{type:"string"}}],!1;if(t.length<1)return e.errors=[{params:{}}],!1}i=s===l}else i=!0;if(i){if(void 0!==r.safe){let t=r.safe;const s=l,o=l;let a=!1;const p=l;if("boolean"!=typeof t){const e={params:{type:"boolean"}};null===n?n=[e]:n.push(e),l++}var f=p===l;if(a=a||f,!a){const e=l;if(l===e)if("string"==typeof t){if(t.length<1){const e={params:{}};null===n?n=[e]:n.push(e),l++}}else{const e={params:{type:"string"}};null===n?n=[e]:n.push(e),l++}f=e===l,a=a||f}if(!a){const r={params:{}};return null===n?n=[r]:n.push(r),l++,e.errors=n,!1}l=o,null!==n&&(o?n.length=o:n=null),i=s===l}else i=!0;if(i)if(void 0!==r.systemvars){const t=l;if("boolean"!=typeof r.systemvars)return e.errors=[{params:{type:"boolean"}}],!1;i=t===l}else i=!0}}}}}}}}return e.errors=n,0===l}module.exports=e,module.exports.default=e;

View File

@ -0,0 +1,53 @@
{
"title": "DotenvPluginOptions",
"type": "object",
"additionalProperties": false,
"properties": {
"allowEmptyValues": {
"description": "Whether to allow empty strings in safe mode. If false, will throw an error if any env variables are empty (but only if safe mode is enabled).",
"type": "boolean"
},
"defaults": {
"description": "Adds support for dotenv-defaults. If set to true, uses ./.env.defaults. If a string, uses that location for a defaults file.",
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"minLength": 1
}
]
},
"expand": {
"description": "Allows your variables to be \"expanded\" for reusability within your .env file.",
"type": "boolean"
},
"path": {
"description": "The path to your environment variables. This same path applies to the .env.example and .env.defaults files.",
"type": "string",
"minLength": 1
},
"prefix": {
"description": "The prefix to use before the name of your env variables.",
"type": "string",
"minLength": 1
},
"safe": {
"description": "If true, load '.env.example' to verify the '.env' variables are all set. Can also be a string to a different file.",
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"minLength": 1
}
]
},
"systemvars": {
"description": "Set to true if you would rather load all system variables as well (useful for CI purposes).",
"type": "boolean"
}
}
}

View File

@ -0,0 +1,7 @@
MY_NODE_ENV=test
API_URL=https://api.example.com
DEBUG=true
PORT=3000
SECRET_KEY=my-secret-key
EMPTY_VALUE=
INTERPOLATED_VAR=$MY_NODE_ENV-mode

View File

@ -0,0 +1,3 @@
MY_NODE_ENV=development
PORT=8080
DEFAULT_VALUE=default-from-defaults

View File

@ -0,0 +1,6 @@
MY_NODE_ENV=
API_URL=
DEBUG=
PORT=
SECRET_KEY=
REQUIRED_VAR=

View File

@ -0,0 +1,10 @@
"use strict";
it("should load basic .env variables", () => {
expect(process.env.MY_NODE_ENV).toBe("test");
expect(process.env.API_URL).toBe("https://api.example.com");
expect(process.env.DEBUG).toBe("true");
expect(process.env.PORT).toBe("3000");
expect(process.env.SECRET_KEY).toBe("my-secret-key");
expect(process.env.EMPTY_VALUE).toBe("");
});

View File

@ -0,0 +1,11 @@
"use strict";
it("should load from custom path", () => {
// When using .env.example as path, values should be empty (as defined in .env.example)
expect(process.env.MY_NODE_ENV).toBe("");
expect(process.env.API_URL).toBe("");
expect(process.env.DEBUG).toBe("");
expect(process.env.PORT).toBe("");
expect(process.env.SECRET_KEY).toBe("");
expect(process.env.REQUIRED_VAR).toBe("");
});

View File

@ -0,0 +1,12 @@
"use strict";
it("should use custom prefix", () => {
expect(MY_ENV.MY_NODE_ENV).toBe("test");
expect(MY_ENV.API_URL).toBe("https://api.example.com");
expect(MY_ENV.DEBUG).toBe("true");
expect(MY_ENV.PORT).toBe("3000");
expect(MY_ENV.SECRET_KEY).toBe("my-secret-key");
// process.env should not be defined with custom prefix
expect(typeof process.env.MY_NODE_ENV).toBe("undefined");
});

View File

@ -0,0 +1,10 @@
"use strict";
it("should load variables with defaults", () => {
// Main .env values should override defaults
expect(process.env.MY_NODE_ENV).toBe("test");
expect(process.env.PORT).toBe("3000");
// Default values should be used when not in main .env
expect(process.env.DEFAULT_VALUE).toBe("default-from-defaults");
});

View File

@ -0,0 +1,3 @@
"use strict";
module.exports = [/Missing environment variable: /];

View File

@ -0,0 +1,6 @@
"use strict";
it("should expand variables when expand is true", () => {
expect(process.env.INTERPOLATED_VAR).toBe("test-mode");
expect(process.env.MY_NODE_ENV).toBe("test");
});

View File

@ -0,0 +1,4 @@
MY_NODE_ENV=test
API_URL=https://api.example.com
DEBUG=true
PORT=3000

View File

@ -0,0 +1,15 @@
"use strict";
it("should include system variables when systemvars is true", () => {
// System variables should be available (we can't predict exact values, but PATH should exist)
expect(typeof process.env.PATH).toBe("string");
expect(process.env.PATH.length).toBeGreaterThan(0);
// .env variables should also be loaded
expect(process.env.MY_NODE_ENV).toBe("test");
expect(process.env.API_URL).toBe("https://api.example.com");
// NODE_ENV might be set by the system/test environment
// We just check that it exists as a system variable
expect(typeof process.env.NODE_ENV).toBe("string");
});

View File

@ -0,0 +1,3 @@
"use strict";
module.exports = [[/Conflicting values for 'process.env.NODE_ENV'/]];

View File

@ -0,0 +1,67 @@
"use strict";
const DotenvPlugin = require("../../../../lib/DotenvPlugin");
/** @type {import("../../../../").Configuration[]} */
module.exports = [
{
name: "basic",
entry: "./basic",
plugins: [new DotenvPlugin()]
},
{
name: "with-defaults",
entry: "./defaults",
plugins: [
new DotenvPlugin({
defaults: true
})
]
},
{
name: "with-expand",
entry: "./expand",
plugins: [
new DotenvPlugin({
expand: true
})
]
},
{
name: "with-systemvars",
entry: "./systemvars",
plugins: [
new DotenvPlugin({
systemvars: true
})
]
},
{
name: "custom-path",
entry: "./custom-path",
plugins: [
new DotenvPlugin({
path: "./.env.example"
})
]
},
{
name: "custom-prefix",
entry: "./custom-prefix",
plugins: [
new DotenvPlugin({
prefix: "MY_ENV."
})
]
},
{
name: "safe-mode-error",
entry: "./basic", // Use existing entry file
plugins: [
new DotenvPlugin({
path: "./incomplete.env",
safe: "./.env.example"
})
]
}
];

119
types.d.ts vendored
View File

@ -4334,6 +4334,124 @@ declare interface DllReferencePluginOptionsManifest {
| "jsonp"
| "system";
}
declare class DotenvPlugin {
constructor(options?: DotenvPluginOptions);
config: {
/**
* Whether to allow empty strings in safe mode. If false, will throw an error if any env variables are empty (but only if safe mode is enabled).
*/
allowEmptyValues?: boolean;
/**
* Adds support for dotenv-defaults. If set to true, uses ./.env.defaults. If a string, uses that location for a defaults file.
*/
defaults?: string | boolean;
/**
* Allows your variables to be "expanded" for reusability within your .env file.
*/
expand?: boolean;
/**
* The path to your environment variables. This same path applies to the .env.example and .env.defaults files.
*/
path: string;
/**
* The prefix to use before the name of your env variables.
*/
prefix: string;
/**
* If true, load '.env.example' to verify the '.env' variables are all set. Can also be a string to a different file.
*/
safe?: string | boolean;
/**
* Set to true if you would rather load all system variables as well (useful for CI purposes).
*/
systemvars?: boolean;
};
/**
* Apply the plugin
*/
apply(compiler: Compiler): void;
gatherVariables(
inputFileSystem: InputFileSystem,
context: string,
callback: (err: null | Error, variables?: Record<string, string>) => void
): void;
initializeVars(): Record<string, string>;
getEnvs(
inputFileSystem: InputFileSystem,
context: string,
callback: (
err: null | Error,
result?: {
env: Record<string, string>;
blueprint: Record<string, string>;
}
) => void
): void;
getDefaults(
inputFileSystem: InputFileSystem,
context: string,
callback: (err: null | Error, content?: string) => void
): void;
/**
* Load a file with proper path resolution
*/
loadFile(
__0: {
/**
* the file to load
*/
file: string;
/**
* the input file system
*/
inputFileSystem: InputFileSystem;
/**
* the compiler context for resolving relative paths
*/
context: string;
},
callback: (err: null | Error, content?: string) => void
): void;
formatDefinitions(variables: Record<string, string>): Record<string, string>;
}
declare interface DotenvPluginOptions {
/**
* Whether to allow empty strings in safe mode. If false, will throw an error if any env variables are empty (but only if safe mode is enabled).
*/
allowEmptyValues?: boolean;
/**
* Adds support for dotenv-defaults. If set to true, uses ./.env.defaults. If a string, uses that location for a defaults file.
*/
defaults?: string | boolean;
/**
* Allows your variables to be "expanded" for reusability within your .env file.
*/
expand?: boolean;
/**
* The path to your environment variables. This same path applies to the .env.example and .env.defaults files.
*/
path?: string;
/**
* The prefix to use before the name of your env variables.
*/
prefix?: string;
/**
* If true, load '.env.example' to verify the '.env' variables are all set. Can also be a string to a different file.
*/
safe?: string | boolean;
/**
* Set to true if you would rather load all system variables as well (useful for CI purposes).
*/
systemvars?: boolean;
}
declare class DynamicEntryPlugin {
constructor(context: string, entry: () => Promise<EntryStaticNormalized>);
context: string;
@ -18883,6 +19001,7 @@ declare namespace exports {
DllPlugin,
DllReferencePlugin,
DynamicEntryPlugin,
DotenvPlugin,
EntryOptionPlugin,
EntryPlugin,
EnvironmentPlugin,