Skip to content
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
156 changes: 134 additions & 22 deletions lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,41 +35,153 @@ function dim (text) {
return supportsAnsi() ? `\x1b[2m${text}\x1b[0m` : text
}

const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg
const KEY_CHAR = new Uint8Array(256)
for (let _i = 48; _i <= 57; _i++) KEY_CHAR[_i] = 1
for (let _i = 65; _i <= 90; _i++) KEY_CHAR[_i] = 1
for (let _i = 97; _i <= 122; _i++) KEY_CHAR[_i] = 1
KEY_CHAR[45] = 1; KEY_CHAR[46] = 1; KEY_CHAR[95] = 1

// Parse src into an Object
// Parse src into an Object — hand-written character scanner (no regex in hot path)
function parse (src) {
const obj = {}
let str = typeof src === 'string' ? src : src.toString()
if (str.indexOf('\r') !== -1) {
str = str.replace(/\r\n?/g, '\n')
}
const len = str.length
let i = 0

// Convert buffer to string
let lines = src.toString()
while (i < len) {
let c = str.charCodeAt(i)

// Convert line breaks to same format
lines = lines.replace(/\r\n?/mg, '\n')
// skip whitespace / blank lines (\r already normalized out)
while (i < len && (c === 32 || c === 9 || c === 10)) {
i++
c = str.charCodeAt(i)
}
if (i >= len) break

let match
while ((match = LINE.exec(lines)) != null) {
const key = match[1]
// comment line
if (c === 35 /* # */) {
while (i < len && str.charCodeAt(i) !== 10) i++
continue
}

// Default undefined or null to empty string
let value = (match[2] || '')
// optional 'export' prefix: 'export' followed by space/tab
if (c === 101 /* e */ && i + 6 < len &&
str.charCodeAt(i + 1) === 120 &&
str.charCodeAt(i + 2) === 112 &&
str.charCodeAt(i + 3) === 111 &&
str.charCodeAt(i + 4) === 114 &&
str.charCodeAt(i + 5) === 116) {
const nc = str.charCodeAt(i + 6)
if (nc === 32 || nc === 9) {
i += 7
while (i < len && ((c = str.charCodeAt(i)) === 32 || c === 9)) i++
} else {
c = str.charCodeAt(i)
}
}

// Remove whitespace
value = value.trim()
// key: [A-Za-z0-9_.-]+ via lookup
const keyStart = i
let stop = 0
while (i < len) {
stop = str.charCodeAt(i)
if (KEY_CHAR[stop]) i++
else break
}
if (i === keyStart) {
while (i < len && str.charCodeAt(i) !== 10) i++
continue
}
const key = str.slice(keyStart, i)
if (i >= len) stop = 0

// Check if double quoted
const maybeQuote = value[0]
// skip spaces/tabs before separator
if (stop === 32 || stop === 9) {
do { i++; stop = i < len ? str.charCodeAt(i) : 0 } while (stop === 32 || stop === 9)
}

// Remove surrounding quotes
value = value.replace(/^(['"`])([\s\S]*)\1$/mg, '$2')
if (stop === 61 /* = */) {
i++
} else if (stop === 58 /* : */ && i + 1 < len && (str.charCodeAt(i + 1) === 32 || str.charCodeAt(i + 1) === 9)) {
i++
} else {
// invalid line — skip
while (i < len && str.charCodeAt(i) !== 10) i++
continue
}

// Expand newlines if double quoted
if (maybeQuote === '"') {
value = value.replace(/\\n/g, '\n')
value = value.replace(/\\r/g, '\r')
// skip spaces/tabs after separator
while (i < len && ((c = str.charCodeAt(i)) === 32 || c === 9)) i++

let value
c = i < len ? str.charCodeAt(i) : 0

if (c === 39 /* ' */ || c === 34 /* " */ || c === 96 /* ` */) {
const quote = c
const vStart = i + 1
let j = vStart
while (j < len) {
const cc = str.charCodeAt(j)
if (cc === 92 /* \ */ && j + 1 < len && str.charCodeAt(j + 1) === quote) {
j += 2
} else if (cc === quote) {
break
} else {
j++
}
}
if (j >= len) {
// unterminated quote — fall back to unquoted-from-here semantics
const uStart = i
let k = i
while (k < len) {
const cc = str.charCodeAt(k)
if (cc === 35 || cc === 10) break
k++
}
let end = k
while (end > uStart) {
const cc = str.charCodeAt(end - 1)
if (cc === 32 || cc === 9) end--
else break
}
value = str.slice(uStart, end)
i = k
if (i < len && str.charCodeAt(i) === 35) {
while (i < len && str.charCodeAt(i) !== 10) i++
}
} else {
value = str.slice(vStart, j)
i = j + 1
if (quote === 34 && value.indexOf('\\') !== -1) {
value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r')
}
// trailing ws + optional comment
while (i < len && ((c = str.charCodeAt(i)) === 32 || c === 9)) i++
if (i < len && str.charCodeAt(i) === 35) {
while (i < len && str.charCodeAt(i) !== 10) i++
}
}
} else {
// unquoted: up to # \n. indexOf for fast \n seek.
const vStart = i
let nl = str.indexOf('\n', i)
if (nl === -1) nl = len
let hash = str.indexOf('#', i)
if (hash === -1 || hash > nl) hash = nl
let end = hash
while (end > vStart) {
const cc = str.charCodeAt(end - 1)
if (cc === 32 || cc === 9) end--
else break
}
value = vStart === end ? '' : str.slice(vStart, end)
i = hash === nl ? hash : nl
}

// Add to object
obj[key] = value
}

Expand Down
49 changes: 49 additions & 0 deletions tests/test-parse-perf.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use strict'
// Performance check for `dotenv.parse()`.
// Not run as part of `npm test` (no TAP assertions); invoke directly:
// node tests/test-parse-perf.js
// Reports median ms over 7 runs of 5000 parse() calls on a representative .env.

const dotenv = require('../lib/main.js')

const sample = [
'# Database',
'DATABASE_URL=postgresql://user:password@localhost:5432/mydb?schema=public',
'REDIS_URL=redis://default:password@localhost:6379',
'',
'# Auth',
'JWT_SECRET=verylongrandomstringthatlookslikeasecretsharedacrossservices',
'OAUTH_GOOGLE_CLIENT_ID=1234567890-abcdefg.apps.googleusercontent.com',
'OAUTH_GITHUB_CLIENT_SECRET=ghp_abcdefghijklmnopqrstuvwxyz',
'',
'# AWS',
'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
'S3_BUCKET=my-app-uploads-prod',
'',
'# Quoted / multiline',
'EMAIL_FROM="MyApp <noreply@myapp.com>"',
'ALLOWED_ORIGINS="https://myapp.com,https://www.myapp.com"',
'MULTILINE_KEY="line one\\nline two\\nline three"',
'',
'# Misc',
'NODE_ENV=production',
'PORT=3000',
'LOG_LEVEL=info',
'FEATURE_FLAG_A=true',
''
].join('\n').repeat(8)

const buf = Buffer.from(sample)
const N = 5000

for (let i = 0; i < 200; i++) dotenv.parse(buf)

const runs = []
for (let r = 0; r < 7; r++) {
const t = process.hrtime.bigint()
for (let i = 0; i < N; i++) dotenv.parse(buf)
runs.push(Number(process.hrtime.bigint() - t) / 1e6)
}
runs.sort(function (a, b) { return a - b })
console.log('parse() x ' + N + ': median ' + runs[Math.floor(runs.length / 2)].toFixed(2) + ' ms')