From 6b9823047011343643e550e27dd74aef5a6fb2c3 Mon Sep 17 00:00:00 2001 From: Wil Wilsman Date: Mon, 19 Jul 2021 10:25:33 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Replace=20dotenv=20with=20custom=20?= =?UTF-8?q?implementation=20(#426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Replace dotenv with custom implementation * ✅ Remove dotenv reference --- packages/env/package.json | 3 -- packages/env/src/dotenv.js | 81 ++++++++++++++++++++++++++------ packages/env/src/index.js | 2 +- packages/env/test/dotenv.test.js | 48 ++++++++++++++++++- yarn.lock | 5 -- 5 files changed, 114 insertions(+), 25 deletions(-) diff --git a/packages/env/package.json b/packages/env/package.json index 12e5ea21c..f49138979 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -20,8 +20,5 @@ }, "devDependencies": { "mock-require": "^3.0.3" - }, - "dependencies": { - "dotenv": "^10.0.0" } } diff --git a/packages/env/src/dotenv.js b/packages/env/src/dotenv.js index 3f03c00fa..9a117f241 100644 --- a/packages/env/src/dotenv.js +++ b/packages/env/src/dotenv.js @@ -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+)?(?[\\w.]+)', + // separator + '(?:\\s*=\\s*?|:\\s+?)(?:', + // single quoted value or + '\\s*(?\')(?(?:\\\\\'|[^\'])*)\'|', + // double quoted value or + '\\s*(?")(?(?:\\\\"|[^"])*)"|', + // unquoted value + '(?[^#\\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 + } } } diff --git a/packages/env/src/index.js b/packages/env/src/index.js index 89743fb29..455f6cf69 100644 --- a/packages/env/src/index.js +++ b/packages/env/src/index.js @@ -1,2 +1,2 @@ export { default } from './environment'; -require('./dotenv').config(); +require('./dotenv').load(); diff --git a/packages/env/test/dotenv.test.js b/packages/env/test/dotenv.test.js index 3cb1a3b30..7860ba0e3 100644 --- a/packages/env/test/dotenv.test.js +++ b/packages/env/test/dotenv.test.js @@ -6,7 +6,6 @@ describe('dotenv files', () => { beforeAll(() => { env = process.env; mock('fs', { readFileSync: path => dotenvs[path] ?? '' }); - mock.reRequire('dotenv'); mock.reRequire('../src/dotenv'); }); @@ -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'; @@ -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'); + }); }); diff --git a/yarn.lock b/yarn.lock index 34bbf2ad2..2eede7922 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"