Skip to content

util: add loadEnvFile utility #59125

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -2135,6 +2135,37 @@ $ node negate.js --no-logfile --logfile=test.log --color --no-color
{ logfile: 'test.log', color: false }
```

## `util.loadEnvFile(path)`

<!-- YAML
added:
- REPLACEME
-->

> Stability: 1.1 - Active development

* `path` {string | URL | Buffer | undefined}. **Default:** `'./.env'`

Parses the `.env` file and returns an object containing its values.

```cjs
const { loadEnvFile } = require('node:util');

// The `.env` file contains the following line: `MY_VAR = my variable`

loadEnvFile();
// Returns { MY_VAR: 'my variable' }
```

```mjs
import { loadEnvFile } from 'node:util';

// The `.env` file contains the following line: `MY_VAR = my variable`

loadEnvFile();
// Returns { MY_VAR: 'my variable' }
```

## `util.parseEnv(content)`

<!-- YAML
Expand Down
19 changes: 7 additions & 12 deletions lib/internal/process/per_thread.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
Float64Array,
FunctionPrototypeCall,
NumberMAX_SAFE_INTEGER,
ObjectAssign,
ObjectDefineProperty,
ObjectEntries,
ObjectFreeze,
Expand Down Expand Up @@ -57,9 +58,9 @@ const {
const dc = require('diagnostics_channel');
const execveDiagnosticChannel = dc.channel('process.execve');

const constants = internalBinding('constants').os.signals;
const util = require('util');

let getValidatedPath; // We need to lazy load it because of the circular dependency.
const constants = internalBinding('constants').os.signals;

const kInternal = Symbol('internal properties');

Expand Down Expand Up @@ -111,7 +112,6 @@ function wrapProcessMethods(binding) {
memoryUsage: _memoryUsage,
rss,
resourceUsage: _resourceUsage,
loadEnvFile: _loadEnvFile,
execve: _execve,
} = binding;

Expand Down Expand Up @@ -352,17 +352,12 @@ function wrapProcessMethods(binding) {
}

/**
* Loads the `.env` file to process.env.
* @param {string | URL | Buffer | undefined} path
* Loads the `.env` file onto `process.env`.
* @param {string | URL | Buffer | uFndefined} path
*/
function loadEnvFile(path = undefined) { // Provide optional value so that `loadEnvFile.length` returns 0
if (path != null) {
getValidatedPath ??= require('internal/fs/utils').getValidatedPath;
path = getValidatedPath(path);
_loadEnvFile(path);
} else {
_loadEnvFile();
}
const loaded = util.loadEnvFile(path);
ObjectAssign(process.env, loaded);
Comment on lines +359 to +360
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given how trivial it is to load a .env file onto process.env I even wonder if we could deprecate/remove process.loadEnvFile.

To me personally it feels much clearer to have this sort of operation more manual and have the user load their .env files withloadEnvFile and then use the values to populate process.env themselves if and how they want (with any overriding, filtering, etc. logic).

}

return {
Expand Down
19 changes: 19 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ function lazyUtilColors() {
}
const { getOptionValue } = require('internal/options');

// We need to lazy load `getValidatedPath` to avoid the circular dependency issues
let getValidatedPath;

const binding = internalBinding('util');

const {
Expand Down Expand Up @@ -320,6 +323,21 @@ function parseEnv(content) {
return binding.parseEnv(content);
}

/**
* Loads the content of a .env file into an object.
* @param {string | URL | Buffer | undefined} path
* @returns {Record<string, string>}
*/
function loadEnvFile(path) {
if (path) {
getValidatedPath ??= require('internal/fs/utils').getValidatedPath;
path = getValidatedPath(path);
return binding.loadEnvFile(path);
}
return binding.loadEnvFile();

}

const lazySourceMap = getLazy(() => require('internal/source_map/source_map_cache'));

/**
Expand Down Expand Up @@ -463,6 +481,7 @@ module.exports = {
},
types,
parseEnv,
loadEnvFile,
};

defineLazyProperties(
Expand Down
2 changes: 0 additions & 2 deletions src/node_process.h
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ class BindingData : public SnapshotableObject {

static void SlowBigInt(const v8::FunctionCallbackInfo<v8::Value>& args);

static void LoadEnvFile(const v8::FunctionCallbackInfo<v8::Value>& args);

private:
// Buffer length in uint32.
static constexpr size_t kHrTimeBufferLength = 3;
Expand Down
37 changes: 0 additions & 37 deletions src/node_process_methods.cc
Original file line number Diff line number Diff line change
Expand Up @@ -592,39 +592,6 @@ static void Execve(const FunctionCallbackInfo<Value>& args) {
}
#endif

static void LoadEnvFile(const v8::FunctionCallbackInfo<v8::Value>& args) {
Environment* env = Environment::GetCurrent(args);
std::string path = ".env";
if (args.Length() == 1) {
BufferValue path_value(args.GetIsolate(), args[0]);
ToNamespacedPath(env, &path_value);
path = path_value.ToString();
}

THROW_IF_INSUFFICIENT_PERMISSIONS(
env, permission::PermissionScope::kFileSystemRead, path);

Dotenv dotenv{};

switch (dotenv.ParsePath(path)) {
case dotenv.ParseResult::Valid: {
USE(dotenv.SetEnvironment(env));
break;
}
case dotenv.ParseResult::InvalidContent: {
THROW_ERR_INVALID_ARG_TYPE(
env, "Contents of '%s' should be a valid string.", path.c_str());
break;
}
case dotenv.ParseResult::FileError: {
env->ThrowUVException(UV_ENOENT, "open", nullptr, path.c_str());
break;
}
default:
UNREACHABLE();
}
}

namespace process {

BindingData::BindingData(Realm* realm,
Expand Down Expand Up @@ -789,8 +756,6 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data,
SetMethodNoSideEffect(isolate, target, "uptime", Uptime);
SetMethod(isolate, target, "patchProcessObject", PatchProcessObject);

SetMethod(isolate, target, "loadEnvFile", LoadEnvFile);

SetMethod(isolate, target, "setEmitWarningSync", SetEmitWarningSync);
}

Expand Down Expand Up @@ -836,8 +801,6 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(Uptime);
registry->Register(PatchProcessObject);

registry->Register(LoadEnvFile);

registry->Register(SetEmitWarningSync);
}

Expand Down
39 changes: 39 additions & 0 deletions src/node_util.cc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#include "node_dotenv.h"
#include "node_errors.h"
#include "node_external_reference.h"
#include "path.h"
#include "util-inl.h"
#include "v8-fast-api-calls.h"

Expand Down Expand Up @@ -249,6 +250,42 @@ static void ParseEnv(const FunctionCallbackInfo<Value>& args) {
}
}

static void LoadEnvFile(const v8::FunctionCallbackInfo<v8::Value>& args) {
Environment* env = Environment::GetCurrent(args);
std::string path = ".env";
if (args.Length() == 1) {
BufferValue path_value(args.GetIsolate(), args[0]);
ToNamespacedPath(env, &path_value);
path = path_value.ToString();
}

THROW_IF_INSUFFICIENT_PERMISSIONS(
env, permission::PermissionScope::kFileSystemRead, path);

Dotenv dotenv{};

switch (dotenv.ParsePath(path)) {
case dotenv.ParseResult::Valid: {
Local<Object> obj;
if (dotenv.ToObject(env).ToLocal(&obj)) {
args.GetReturnValue().Set(obj);
}
break;
}
case dotenv.ParseResult::InvalidContent: {
THROW_ERR_INVALID_ARG_TYPE(
env, "Contents of '%s' should be a valid string.", path.c_str());
break;
}
case dotenv.ParseResult::FileError: {
env->ThrowUVException(UV_ENOENT, "open", nullptr, path.c_str());
break;
}
default:
UNREACHABLE();
}
}

static void GetCallSites(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();
Expand Down Expand Up @@ -444,6 +481,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(GuessHandleType);
registry->Register(fast_guess_handle_type_);
registry->Register(ParseEnv);
registry->Register(LoadEnvFile);
registry->Register(IsInsideNodeModules);
registry->Register(DefineLazyProperties);
registry->Register(DefineLazyPropertiesGetter);
Expand Down Expand Up @@ -546,6 +584,7 @@ void Initialize(Local<Object> target,
SetMethodNoSideEffect(context, target, "getCallSites", GetCallSites);
SetMethod(context, target, "sleep", Sleep);
SetMethod(context, target, "parseEnv", ParseEnv);
SetMethod(context, target, "loadEnvFile", LoadEnvFile);

SetMethod(
context, target, "arrayBufferViewHasBuffer", ArrayBufferViewHasBuffer);
Expand Down
56 changes: 14 additions & 42 deletions test/parallel/test-dotenv-edge-cases.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ const common = require('../common');
const assert = require('node:assert');
const path = require('node:path');
const { describe, it } = require('node:test');
const { parseEnv } = require('node:util');
const fixtures = require('../common/fixtures');
const { parseEnv, loadEnvFile } = require('node:util');

const validEnvFilePath = '../fixtures/dotenv/valid.env';
const multilineEnvFilePath = '../fixtures/dotenv/multiline.env';
const linesWithOnlySpacesEnvFilePath = '../fixtures/dotenv/lines-with-only-spaces.env';
const eofWithoutValueEnvFilePath = '../fixtures/dotenv/eof-without-value.env';
const nodeOptionsEnvFilePath = '../fixtures/dotenv/node-options.env';
const noFinalNewlineEnvFilePath = '../fixtures/dotenv/no-final-newline.env';
const noFinalNewlineSingleQuotesEnvFilePath = '../fixtures/dotenv/no-final-newline-single-quotes.env';
Expand Down Expand Up @@ -106,54 +108,24 @@ describe('.env supports edge cases', () => {

it('should handle multiline quoted values', async () => {
// Ref: https://github.com/nodejs/node/issues/52248
const code = `
process.loadEnvFile('./multiline.env');
require('node:assert').ok(process.env.JWT_PUBLIC_KEY);
`.trim();
const child = await common.spawnPromisified(
process.execPath,
[ '--eval', code ],
{ cwd: fixtures.path('dotenv') },
);
assert.strictEqual(child.stdout, '');
assert.strictEqual(child.stderr, '');
assert.strictEqual(child.code, 0);
const obj = loadEnvFile(path.resolve(__dirname, multilineEnvFilePath));
assert.match(obj.JWT_PUBLIC_KEY, /-----BEGIN PUBLIC KEY-----\n[\s\S]*\n-----END PUBLIC KEY-----*/);
});

it('should handle empty value without a newline at the EOF', async () => {
// Ref: https://github.com/nodejs/node/issues/52466
const code = `
process.loadEnvFile('./eof-without-value.env');
assert.strictEqual(process.env.BASIC, 'value');
assert.strictEqual(process.env.EMPTY, '');
`.trim();
const child = await common.spawnPromisified(
process.execPath,
[ '--eval', code ],
{ cwd: fixtures.path('dotenv') },
);
assert.strictEqual(child.stdout, '');
assert.strictEqual(child.stderr, '');
assert.strictEqual(child.code, 0);
const obj = loadEnvFile(path.resolve(__dirname, eofWithoutValueEnvFilePath));
assert.strictEqual(obj.BASIC, 'value');
assert.strictEqual(obj.EMPTY, '');
});

it('should handle lines that come after lines with only spaces (and tabs)', async () => {
// Ref: https://github.com/nodejs/node/issues/56686
const code = `
process.loadEnvFile('./lines-with-only-spaces.env');
assert.strictEqual(process.env.EMPTY_LINE, 'value after an empty line');
assert.strictEqual(process.env.SPACES_LINE, 'value after a line with just some spaces');
assert.strictEqual(process.env.TABS_LINE, 'value after a line with just some tabs');
assert.strictEqual(process.env.SPACES_TABS_LINE, 'value after a line with just some spaces and tabs');
`.trim();
const child = await common.spawnPromisified(
process.execPath,
[ '--eval', code ],
{ cwd: fixtures.path('dotenv') },
);
assert.strictEqual(child.stdout, '');
assert.strictEqual(child.stderr, '');
assert.strictEqual(child.code, 0);
const obj = loadEnvFile(path.resolve(__dirname, linesWithOnlySpacesEnvFilePath));
assert.strictEqual(obj.EMPTY_LINE, 'value after an empty line');
assert.strictEqual(obj.SPACES_LINE, 'value after a line with just some spaces');
assert.strictEqual(obj.TABS_LINE, 'value after a line with just some tabs');
assert.strictEqual(obj.SPACES_TABS_LINE, 'value after a line with just some spaces and tabs');
});

it('should handle when --env-file is passed along with --', async () => {
Expand Down
4 changes: 2 additions & 2 deletions test/parallel/test-process-load-env-file.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,10 @@ describe('process.loadEnvFile()', () => {
assert.strictEqual(child.code, 1);
});

it('loadEnvFile does not mutate --env-file output', async () => {
it('loadEnvFile overrides env vars loaded with --env-file', async () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a breaking change, but I hope it's fine given that dotenv is experimental (and I don't imagine many developers relying on this behavior? 🤔)

I am also not sure about the rational, personally if a new env file is loaded onto process.env I would expect that it would override whatever else is there 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a problem I can keep the existing behavior.

Since I am thinking of making more changes in this space, to avoid churn and I would prefer this PR not to be a semver-major one if possible.

const code = `
process.loadEnvFile(${JSON.stringify(basicValidEnvFilePath)});
require('assert')(process.env.BASIC === 'basic');
require('node:assert').strictEqual(process.env.BASIC, 'overriden');
`.trim();
const child = await common.spawnPromisified(
process.execPath,
Expand Down
Loading
Loading