Skip to content

Commit

Permalink
src: add built-in .env file support
Browse files Browse the repository at this point in the history
  • Loading branch information
anonrig committed Jul 24, 2023
1 parent 6c08b1f commit 24aba01
Show file tree
Hide file tree
Showing 9 changed files with 287 additions and 0 deletions.
26 changes: 26 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,32 @@ surface on other platforms, but the performance impact may be severe.
This flag is inherited from V8 and is subject to change upstream. It may
disappear in a non-semver-major release.

### `--env-file=config`

> Stability: 1 - Experimental
<!-- YAML
added: REPLACEME
-->

Loads environment variables from a file relative to the current directory.

`NODE_OPTIONS` environment variable is not supported at the moment.

The format of the file should be one line per key-value pair of environment
variable name and value separated by `=`:

```text
PORT=3000
```

Any text after a `#` is treated as a comment:

```text
# This is a comment
PORT=3000 # This is also a comment
```

### `--max-http-header-size=size`

<!-- YAML
Expand Down
2 changes: 2 additions & 0 deletions node.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
'src/node_contextify.cc',
'src/node_credentials.cc',
'src/node_dir.cc',
'src/node_dotenv.cc',
'src/node_env_var.cc',
'src/node_errors.cc',
'src/node_external_reference.cc',
Expand Down Expand Up @@ -214,6 +215,7 @@
'src/node_context_data.h',
'src/node_contextify.h',
'src/node_dir.h',
'src/node_dotenv.h',
'src/node_errors.h',
'src/node_exit_code.h',
'src/node_external_reference.h',
Expand Down
6 changes: 6 additions & 0 deletions src/node.cc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
// USE OR OTHER DEALINGS IN THE SOFTWARE.

#include "node.h"
#include "node_dotenv.h"

// ========== local headers ==========

Expand Down Expand Up @@ -303,6 +304,11 @@ MaybeLocal<Value> StartExecution(Environment* env, StartExecutionCallback cb) {
}
#endif

if (env->options()->has_env_file_string) {
std::string path = env->GetCwd() + kPathSeparator + env->options()->env_file;
node::dotenv::LoadFromFile(env, path);
}

// TODO(joyeecheung): move these conditions into JS land and let the
// deserialize main function take precedence. For workers, we need to
// move the pre-execution part into a different file that can be
Expand Down
115 changes: 115 additions & 0 deletions src/node_dotenv.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#include "env-inl.h"
#include "node_dotenv.h"
#include "node_file.h"
#include "uv.h"

namespace node {

using v8::Isolate;
using v8::NewStringType;

namespace dotenv {

void ParseLine(const std::string_view line, Isolate* isolate, std::shared_ptr<KVStore> store) {
auto equal_index = line.find('=');

if (equal_index == std::string_view::npos) {
return;
}

auto key = line.substr(0, equal_index);

// Remove leading and trailing space characters from key.
while (!key.empty() && std::isspace(key.front())) key.remove_prefix(1);
while (!key.empty() && std::isspace(key.back())) key.remove_suffix(1);

// Omit lines with comments
if (key.front() == '#' || key.empty()) {
return;
}

auto value = std::string(line.substr(equal_index + 1));

// Might start and end with `"' characters.
auto quotation_index = value.find_first_of("`\"'");

if (quotation_index == 0) {
auto quote_character = value[quotation_index];
value.erase(0, 1);

auto end_quotation_index = value.find_last_of(quote_character);

// We couldn't find the closing quotation character. Terminate.
if (end_quotation_index == std::string::npos) {
return;
}

value.erase(end_quotation_index);
} else {
auto hash_index = value.find('#');

// Remove any inline comments
if (hash_index != std::string::npos) {
value.erase(hash_index);
}

// Remove any leading/trailing spaces from unquoted values.
while (!value.empty() && std::isspace(value.front())) value.erase(0, 1);
while (!value.empty() && std::isspace(value.back())) value.erase(value.size() - 1);
}

store->Set(isolate,
v8::String::NewFromUtf8(
isolate, key.data(), NewStringType::kNormal, key.size())
.ToLocalChecked(),
v8::String::NewFromUtf8(
isolate, value.data(), NewStringType::kNormal, value.size())
.ToLocalChecked());
}

void LoadFromFile(Environment* env, const std::string_view path) {
Isolate* isolate = env->isolate();
auto store = env->env_vars();
uv_fs_t req;
auto defer_req_cleanup = OnScopeLeave([&req]() { uv_fs_req_cleanup(&req); });

uv_file file = uv_fs_open(nullptr, &req, path.data(), 0, 438, nullptr);
if (req.result < 0) {
// req will be cleaned up by scope leave.
return;
}
uv_fs_req_cleanup(&req);

auto defer_close = OnScopeLeave([file]() {
uv_fs_t close_req;
CHECK_EQ(0, uv_fs_close(nullptr, &close_req, file, nullptr));
uv_fs_req_cleanup(&close_req);
});

std::string result{};
char buffer[8192];
uv_buf_t buf = uv_buf_init(buffer, sizeof(buffer));

while (true) {
auto r = uv_fs_read(nullptr, &req, file, &buf, 1, -1, nullptr);
if (req.result < 0) {
// req will be cleaned up by scope leave.
return;
}
uv_fs_req_cleanup(&req);
if (r <= 0) {
break;
}
result.append(buf.base, r);
}

using std::string_view_literals::operator""sv;

for (const auto& line : SplitString(result, "\n"sv)) {
ParseLine(line, isolate, store);
}
}

} // namespace dotenv

} // namespace node
21 changes: 21 additions & 0 deletions src/node_dotenv.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#ifndef SRC_NODE_DOTENV_H_
#define SRC_NODE_DOTENV_H_

#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS

#include "util-inl.h"

namespace node {

namespace dotenv {

void LoadFromFile(Environment* env,
const std::string_view path);

} // namespace dotenv

} // namespace node

#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS

#endif // SRC_NODE_DOTENV_H_
5 changes: 5 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
"write warnings to file instead of stderr",
&EnvironmentOptions::redirect_warnings,
kAllowedInEnvvar);
AddOption("[has_env_file_string]", "", &EnvironmentOptions::has_env_file_string);
AddOption("--env-file",
"set environment variables from supplied file",
&EnvironmentOptions::env_file);
Implies("--env-file", "[has_env_file_string]");
AddOption("--test",
"launch test runner on startup",
&EnvironmentOptions::test_runner);
Expand Down
2 changes: 2 additions & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ class EnvironmentOptions : public Options {
#endif // HAVE_INSPECTOR
std::string redirect_warnings;
std::string diagnostic_dir;
std::string env_file;
bool has_env_file_string = false;
bool test_runner = false;
bool test_runner_coverage = false;
std::vector<std::string> test_name_pattern;
Expand Down
38 changes: 38 additions & 0 deletions test/fixtures/dotenv/valid.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
BASIC=basic

# previous line intentionally left blank
AFTER_LINE=after_line
EMPTY=
EMPTY_SINGLE_QUOTES=''
EMPTY_DOUBLE_QUOTES=""
EMPTY_BACKTICKS=``
SINGLE_QUOTES='single_quotes'
SINGLE_QUOTES_SPACED=' single quotes '
DOUBLE_QUOTES="double_quotes"
DOUBLE_QUOTES_SPACED=" double quotes "
DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes'
DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET="{ port: $MONGOLAB_PORT}"
SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes"
BACKTICKS_INSIDE_SINGLE='`backticks` work inside single quotes'
BACKTICKS_INSIDE_DOUBLE="`backticks` work inside double quotes"
BACKTICKS=`backticks`
BACKTICKS_SPACED=` backticks `
DOUBLE_QUOTES_INSIDE_BACKTICKS=`double "quotes" work inside backticks`
SINGLE_QUOTES_INSIDE_BACKTICKS=`single 'quotes' work inside backticks`
DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS=`double "quotes" and single 'quotes' work inside backticks`
EXPAND_NEWLINES="expand\nnew\nlines"
DONT_EXPAND_UNQUOTED=dontexpand\nnewlines
DONT_EXPAND_SQUOTED='dontexpand\nnewlines'
# COMMENTS=work
INLINE_COMMENTS=inline comments # work #very #well
INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work
INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work
INLINE_COMMENTS_BACKTICKS=`inline comments outside of #backticks` # work
INLINE_COMMENTS_SPACE=inline comments start with a#number sign. no space required.
EQUAL_SIGNS=equals==
RETAIN_INNER_QUOTES={"foo": "bar"}
RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}'
RETAIN_INNER_QUOTES_AS_BACKTICKS=`{"foo": "bar's"}`
TRIM_SPACE_FROM_UNQUOTED= some spaced out string
USERNAME=therealnerdybeast@example.tld
SPACED_KEY = parsed
72 changes: 72 additions & 0 deletions test/parallel/test-dotenv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Flags: --env-file test/fixtures/dotenv/valid.env
'use strict';

const common = require('../common');

Check failure on line 4 in test/parallel/test-dotenv.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'common' is assigned a value but never used
const assert = require('node:assert');
const fs = require('node:fs');

Check failure on line 6 in test/parallel/test-dotenv.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'fs' is assigned a value but never used
const path = require('node:path');

Check failure on line 7 in test/parallel/test-dotenv.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'path' is assigned a value but never used

// Sets basic environment variable
assert.strictEqual(process.env.BASIC, 'basic');
// Reads after a skipped line
assert.strictEqual(process.env.AFTER_LINE, 'after_line');
// Defaults empty values to empty string
assert.strictEqual(process.env.EMPTY, '');
assert.strictEqual(process.env.EMPTY_SINGLE_QUOTES, '');
assert.strictEqual(process.env.EMPTY_DOUBLE_QUOTES, '');
assert.strictEqual(process.env.EMPTY_BACKTICKS, '');
// Escapes single quoted values
assert.strictEqual(process.env.SINGLE_QUOTES, 'single_quotes');
// Respects surrounding spaces in single quotes
assert.strictEqual(process.env.SINGLE_QUOTES_SPACED, ' single quotes ');
// Escapes double quoted values
assert.strictEqual(process.env.DOUBLE_QUOTES, 'double_quotes');
// Respects surrounding spaces in double quotes
assert.strictEqual(process.env.DOUBLE_QUOTES_SPACED, ' double quotes ');
// Respects double quotes inside single quotes
assert.strictEqual(process.env.DOUBLE_QUOTES_INSIDE_SINGLE, 'double "quotes" work inside single quotes');
// Respects spacing for badly formed brackets
assert.strictEqual(process.env.DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET, '{ port: $MONGOLAB_PORT}');
// Respects single quotes inside double quotes
assert.strictEqual(process.env.SINGLE_QUOTES_INSIDE_DOUBLE, "single 'quotes' work inside double quotes");
// Respects backticks inside single quotes
assert.strictEqual(process.env.BACKTICKS_INSIDE_SINGLE, '`backticks` work inside single quotes');
// Respects backticks inside double quotes
assert.strictEqual(process.env.BACKTICKS_INSIDE_DOUBLE, '`backticks` work inside double quotes');
assert.strictEqual(process.env.BACKTICKS, 'backticks');
assert.strictEqual(process.env.BACKTICKS_SPACED, ' backticks ');
// Respects double quotes inside backticks
assert.strictEqual(process.env.DOUBLE_QUOTES_INSIDE_BACKTICKS, 'double "quotes" work inside backticks');
// Respects single quotes inside backticks
assert.strictEqual(process.env.SINGLE_QUOTES_INSIDE_BACKTICKS, "single 'quotes' work inside backticks");
// Respects single quotes inside backticks
assert.strictEqual(
process.env.DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS,
"double \"quotes\" and single 'quotes' work inside backticks",
);
// Ignores inline comments
assert.strictEqual(process.env.INLINE_COMMENTS, 'inline comments');
// Ignores inline comments and respects # character inside of single quotes
assert.strictEqual(process.env.INLINE_COMMENTS_SINGLE_QUOTES, 'inline comments outside of #singlequotes');
// Ignores inline comments and respects # character inside of double quotes
assert.strictEqual(process.env.INLINE_COMMENTS_DOUBLE_QUOTES, 'inline comments outside of #doublequotes');
// Ignores inline comments and respects # character inside of backticks
assert.strictEqual(process.env.INLINE_COMMENTS_BACKTICKS, 'inline comments outside of #backticks');
// Treats # character as start of comment
assert.strictEqual(process.env.INLINE_COMMENTS_SPACE, 'inline comments start with a');
// Respects equals signs in values
assert.strictEqual(process.env.EQUAL_SIGNS, 'equals==');
// Retains inner quotes
assert.strictEqual(process.env.RETAIN_INNER_QUOTES, '{"foo": "bar"}');
// Respects equals signs in values
assert.strictEqual(process.env.EQUAL_SIGNS, 'equals==');
// Retains inner quotes
assert.strictEqual(process.env.RETAIN_INNER_QUOTES, '{"foo": "bar"}');
assert.strictEqual(process.env.RETAIN_INNER_QUOTES_AS_STRING, '{"foo": "bar"}');
assert.strictEqual(process.env.RETAIN_INNER_QUOTES_AS_BACKTICKS, '{"foo": "bar\'s"}');
// Retains spaces in string
assert.strictEqual(process.env.TRIM_SPACE_FROM_UNQUOTED, 'some spaced out string');
// Parses email addresses completely
assert.strictEqual(process.env.USERNAME, 'therealnerdybeast@example.tld');
// Parses keys and values surrounded by spaces
assert.strictEqual(process.env.SPACED_KEY, 'parsed');

0 comments on commit 24aba01

Please sign in to comment.