diff --git a/src/bun.js/node/node_util_binding.zig b/src/bun.js/node/node_util_binding.zig index c2fe3a7288..97ac1b2207 100644 --- a/src/bun.js/node/node_util_binding.zig +++ b/src/bun.js/node/node_util_binding.zig @@ -7,6 +7,8 @@ const string = bun.string; const Output = bun.Output; const ZigString = JSC.ZigString; const uv = bun.windows.libuv; +const validators = @import("./util/validators.zig"); +const envloader = @import("./../../env_loader.zig"); pub fn internalErrorName(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { const arguments = callframe.arguments_old(1).slice(); @@ -212,3 +214,24 @@ pub fn normalizeEncoding(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFr if (str.inMapCaseInsensitive(JSC.Node.Encoding.map)) |enc| return enc.toJS(globalThis); return JSC.JSValue.jsUndefined(); } + +pub fn parseEnv(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { + const content = callframe.argument(0); + try validators.validateString(globalThis, content, "content", .{}); + + var arena = std.heap.ArenaAllocator.init(bun.default_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const str = content.asString().toSlice(globalThis, allocator); + + var map = envloader.Map.init(allocator); + var p = envloader.Loader.init(&map, allocator); + p.loadFromString(str.slice(), true, false); + + var obj = JSC.JSValue.createEmptyObject(globalThis, map.map.count()); + for (map.map.keys(), map.map.values()) |k, v| { + obj.put(globalThis, JSC.ZigString.initUTF8(k), bun.String.createUTF8ForJS(globalThis, v.value)); + } + return obj; +} diff --git a/src/env_loader.zig b/src/env_loader.zig index 954f9164d9..a8e224b49e 100644 --- a/src/env_loader.zig +++ b/src/env_loader.zig @@ -538,9 +538,9 @@ pub const Loader = struct { } // mostly for tests - pub fn loadFromString(this: *Loader, str: string, comptime overwrite: bool) void { + pub fn loadFromString(this: *Loader, str: string, comptime overwrite: bool, comptime expand: bool) void { var source = logger.Source.initPathString("test", str); - Parser.parse(&source, this.allocator, this.map, overwrite, false); + Parser.parse(&source, this.allocator, this.map, overwrite, false, expand); std.mem.doNotOptimizeAway(&source); } @@ -803,6 +803,7 @@ pub const Loader = struct { this.map, override, false, + true, ); @field(this, base) = source; @@ -873,6 +874,7 @@ pub const Loader = struct { this.map, override, false, + true, ); try this.custom_files_loaded.put(file_path, source); @@ -1097,6 +1099,7 @@ const Parser = struct { map: *Map, comptime override: bool, comptime is_process: bool, + comptime expand: bool, ) void { var count = map.map.count(); while (this.pos < this.src.len) { @@ -1120,7 +1123,7 @@ const Parser = struct { .conditional = false, }; } - if (comptime !is_process) { + if (comptime !is_process and expand) { var it = map.iterator(); while (it.next()) |entry| { if (count > 0) { @@ -1142,9 +1145,10 @@ const Parser = struct { map: *Map, comptime override: bool, comptime is_process: bool, + comptime expand: bool, ) void { var parser = Parser{ .src = source.contents }; - parser._parse(allocator, map, override, is_process); + parser._parse(allocator, map, override, is_process, expand); } }; diff --git a/src/js/node/util.ts b/src/js/node/util.ts index 16eb2479ab..3b66a6bb71 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -6,6 +6,7 @@ const { promisify } = require("internal/promisify"); const { validateString, validateOneOf } = require("internal/validators"); const internalErrorName = $newZigFunction("node_util_binding.zig", "internalErrorName", 1); +const parseEnv = $newZigFunction("node_util_binding.zig", "parseEnv", 1); const NumberIsSafeInteger = Number.isSafeInteger; const ObjectKeys = Object.keys; @@ -347,7 +348,7 @@ cjs_exports = { // transferableAbortController, aborted, types, - // parseEnv, + parseEnv, parseArgs, TextDecoder, TextEncoder, diff --git a/test/js/node/test/fixtures/dotenv/valid.env b/test/js/node/test/fixtures/dotenv/valid.env index 963c4c848a..120488d579 100644 --- a/test/js/node/test/fixtures/dotenv/valid.env +++ b/test/js/node/test/fixtures/dotenv/valid.env @@ -38,6 +38,7 @@ 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 +SPACE_BEFORE_DOUBLE_QUOTES= "space before double quotes" EMAIL=therealnerdybeast@example.tld SPACED_KEY = parsed EDGE_CASE_INLINE_COMMENTS="VALUE1" # or "VALUE2" or "VALUE3" diff --git a/test/js/node/test/parallel/test-util-parse-env.js b/test/js/node/test/parallel/test-util-parse-env.js new file mode 100644 index 0000000000..13d2fda37a --- /dev/null +++ b/test/js/node/test/parallel/test-util-parse-env.js @@ -0,0 +1,70 @@ +'use strict'; + +require('../common'); +const fixtures = require('../../test/common/fixtures'); +const assert = require('node:assert'); +const util = require('node:util'); +const fs = require('node:fs'); + +{ + const validEnvFilePath = fixtures.path('dotenv/valid.env'); + const validContent = fs.readFileSync(validEnvFilePath, 'utf8'); + + assert.deepStrictEqual(util.parseEnv(validContent), { + AFTER_LINE: 'after_line', + BACKTICKS: 'backticks', + BACKTICKS_INSIDE_DOUBLE: '`backticks` work inside double quotes', + BACKTICKS_INSIDE_SINGLE: '`backticks` work inside single quotes', + BACKTICKS_SPACED: ' backticks ', + BASIC: 'basic', + DONT_EXPAND_SQUOTED: 'dontexpand\\nnewlines', + DONT_EXPAND_UNQUOTED: 'dontexpand\\nnewlines', + DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS: "double \"quotes\" and single 'quotes' work inside backticks", + DOUBLE_QUOTES: 'double_quotes', + DOUBLE_QUOTES_INSIDE_BACKTICKS: 'double "quotes" work inside backticks', + DOUBLE_QUOTES_INSIDE_SINGLE: 'double "quotes" work inside single quotes', + DOUBLE_QUOTES_SPACED: ' double quotes ', + DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET: '{ port: $MONGOLAB_PORT}', + EDGE_CASE_INLINE_COMMENTS: 'VALUE1', + EMAIL: 'therealnerdybeast@example.tld', + EMPTY: '', + EMPTY_BACKTICKS: '', + EMPTY_DOUBLE_QUOTES: '', + EMPTY_SINGLE_QUOTES: '', + EQUAL_SIGNS: 'equals==', + EXPORT_EXAMPLE: 'ignore export', + EXPAND_NEWLINES: 'expand\nnew\nlines', + INLINE_COMMENTS: 'inline comments', + INLINE_COMMENTS_BACKTICKS: 'inline comments outside of #backticks', + INLINE_COMMENTS_DOUBLE_QUOTES: 'inline comments outside of #doublequotes', + INLINE_COMMENTS_SINGLE_QUOTES: 'inline comments outside of #singlequotes', + INLINE_COMMENTS_SPACE: 'inline comments start with a', + MULTI_BACKTICKED: 'THIS\nIS\nA\n"MULTILINE\'S"\nSTRING', + MULTI_DOUBLE_QUOTED: 'THIS\nIS\nA\nMULTILINE\nSTRING', + MULTI_NOT_VALID: 'THIS', + MULTI_NOT_VALID_QUOTE: '"', + MULTI_SINGLE_QUOTED: 'THIS\nIS\nA\nMULTILINE\nSTRING', + RETAIN_INNER_QUOTES: '{"foo": "bar"}', + RETAIN_INNER_QUOTES_AS_BACKTICKS: '{"foo": "bar\'s"}', + RETAIN_INNER_QUOTES_AS_STRING: '{"foo": "bar"}', + SINGLE_QUOTES: 'single_quotes', + SINGLE_QUOTES_INSIDE_BACKTICKS: "single 'quotes' work inside backticks", + SINGLE_QUOTES_INSIDE_DOUBLE: "single 'quotes' work inside double quotes", + SINGLE_QUOTES_SPACED: ' single quotes ', + SPACED_KEY: 'parsed', + SPACE_BEFORE_DOUBLE_QUOTES: 'space before double quotes', + TRIM_SPACE_FROM_UNQUOTED: 'some spaced out string', + }); +} + +assert.deepStrictEqual(util.parseEnv(''), {}); +assert.deepStrictEqual(util.parseEnv('FOO=bar\nFOO=baz\n'), { FOO: 'baz' }); + +// Test for invalid input. +assert.throws(() => { + for (const value of [null, undefined, {}, []]) { + util.parseEnv(value); + } +}, { + code: 'ERR_INVALID_ARG_TYPE', +});