Skip to content

Commit

Permalink
✨ Replace dotenv with custom implementation (#426)
Browse files Browse the repository at this point in the history
* ✨ Replace dotenv with custom implementation

* ✅ Remove dotenv reference
  • Loading branch information
Wil Wilsman authored Jul 19, 2021
1 parent 2ee7898 commit 6b98230
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 25 deletions.
3 changes: 0 additions & 3 deletions packages/env/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,5 @@
},
"devDependencies": {
"mock-require": "^3.0.3"
},
"dependencies": {
"dotenv": "^10.0.0"
}
}
81 changes: 66 additions & 15 deletions packages/env/src/dotenv.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,76 @@
const dotenv = require('dotenv');
import fs from 'fs';

// mimic dotenv-rails file hierarchy
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
export function config() {
let {
NODE_ENV: env,
PERCY_DISABLE_DOTENV: disable
} = process.env;
// Heavily inspired by dotenv-rails
// https://github.com/bkeepers/dotenv

// matches each valid line of a dotenv file
const LINE_REG = new RegExp([
// key with optional export
'^\\s*(?:export\\s+)?(?<key>[\\w.]+)',
// separator
'(?:\\s*=\\s*?|:\\s+?)(?:',
// single quoted value or
'\\s*(?<squote>\')(?<sval>(?:\\\\\'|[^\'])*)\'|',
// double quoted value or
'\\s*(?<dquote>")(?<dval>(?:\\\\"|[^"])*)"|',
// unquoted value
'(?<uval>[^#\\r\\n]+))?',
// optional comment
'\\s*(?:#.*)?$'
].join(''), 'gm');

// interpolate variable substitutions
const INTERPOLATE_REG = /(.?)(\${?([a-zA-Z0-9_]+)?}?)/g;
// expand newlines
const EXPAND_CRLF_REG = /\\(?:(r)|n)/g;
// unescape characters
const UNESC_CHAR_REG = /\\([^$])/g;

export function load() {
// don't load dotenv files when disabled
if (disable) return;
if (process.env.PERCY_DISABLE_DOTENV) return;
let { NODE_ENV } = process.env;

// dotenv filepaths ordered by priority
let paths = [
env && `.env.${env}.local`,
// .env.local is not loaded in test environments
env === 'test' ? null : '.env.local',
env && `.env.${env}`,
NODE_ENV && `.env.${NODE_ENV}.local`,
NODE_ENV !== 'test' && '.env.local',
NODE_ENV && `.env.${NODE_ENV}`,
'.env'
].filter(Boolean);
];

// load each dotenv file synchronously
for (let path of paths) {
dotenv.config({ path });
try {
let src = fs.readFileSync(path, { encoding: 'utf-8' });

// iterate over each matching line
for (let { groups: match } of src.matchAll(LINE_REG)) {
let value = match.sval ?? match.dval ?? match.uval ?? '';

// if double quoted, expand newlines
if (match.dquote) {
value = value.replace(EXPAND_CRLF_REG, (_, r) => r ? '\r' : '\n');
}

// unescape characters
value = value.replace(UNESC_CHAR_REG, '$1');

// if not single quoted, interpolate substitutions
if (!match.squote) {
value = value.replace(INTERPOLATE_REG, (_, pre, ref, key) => {
if (pre === '\\') return ref; // escaped reference
return pre + (process.env[key] ?? '');
});
}

// set process.env if not already
if (!Object.prototype.hasOwnProperty.call(process.env, match.key)) {
process.env[match.key] = value;
}
}
} catch (e) {
// silent error
}
}
}
2 changes: 1 addition & 1 deletion packages/env/src/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { default } from './environment';
require('./dotenv').config();
require('./dotenv').load();
48 changes: 47 additions & 1 deletion packages/env/test/dotenv.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ describe('dotenv files', () => {
beforeAll(() => {
env = process.env;
mock('fs', { readFileSync: path => dotenvs[path] ?? '' });
mock.reRequire('dotenv');
mock.reRequire('../src/dotenv');
});

Expand All @@ -30,6 +29,13 @@ describe('dotenv files', () => {
expect(process.env).toHaveProperty('TEST_3', '3');
});

it('does not override existing environment variables', () => {
process.env.TEST_1 = 'uno';
mock.reRequire('../src');

expect(process.env).toHaveProperty('TEST_1', 'uno');
});

it('loads environment specific .env and .env.local files', () => {
dotenvs['.env.dev'] = 'TEST_3=dev_3';
dotenvs['.env.dev.local'] = 'TEST_2=dev_two';
Expand All @@ -56,4 +62,44 @@ describe('dotenv files', () => {
mock.reRequire('../src');
expect(process.env).toEqual({ PERCY_DISABLE_DOTENV: 'true' });
});

it('expands newlines within double quotes', () => {
dotenvs['.env'] = 'TEST_NEWLINES="foo\nbar\r\nbaz\\nqux\\r\\nxyzzy"';
mock.reRequire('../src');

expect(process.env).toHaveProperty('TEST_NEWLINES', 'foo\nbar\r\nbaz\nqux\r\nxyzzy');
});

it('interpolates variable substitutions', () => {
// eslint-disable-next-line no-template-curly-in-string
dotenvs['.env'] += '\nTEST_4=$TEST_1${TEST_2}\nTEST_5=$TEST_4${TEST_3}four';
mock.reRequire('../src');

expect(process.env).toHaveProperty('TEST_4', '1two');
expect(process.env).toHaveProperty('TEST_5', '1two3four');
});

it('interpolates undefined variables with empty strings', () => {
// eslint-disable-next-line no-template-curly-in-string
dotenvs['.env'] += '\nTEST_TWO=2 > ${TEST_ONE}\nTEST_THREE=';
mock.reRequire('../src');

expect(process.env).not.toHaveProperty('TEST_ONE');
expect(process.env).toHaveProperty('TEST_TWO', '2 > ');
expect(process.env).toHaveProperty('TEST_THREE', '');
});

it('does not interpolate single quoted strings', () => {
dotenvs['.env'] += "\nTEST_STRING='$TEST_1'";
mock.reRequire('../src');

expect(process.env).toHaveProperty('TEST_STRING', '$TEST_1');
});

it('does not interpolate escaped dollar signs', () => {
dotenvs['.env'] += '\nTEST_ESC=\\$TEST_1';
mock.reRequire('../src');

expect(process.env).toHaveProperty('TEST_ESC', '$TEST_1');
});
});
5 changes: 0 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3636,11 +3636,6 @@ dot-prop@^6.0.1:
dependencies:
is-obj "^2.0.0"

dotenv@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81"
integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==

duplexer@^0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
Expand Down

0 comments on commit 6b98230

Please sign in to comment.