diff --git a/integration-tests/cli/env.test.js b/integration-tests/cli/env.test.js new file mode 100644 index 0000000000..3175a25bde --- /dev/null +++ b/integration-tests/cli/env.test.js @@ -0,0 +1,169 @@ +import test from 'brittle'; +import { EnvParser } from '../../src/env.js'; + +test('EnvParser should parse single key-value pair', function (t) { + const parser = new EnvParser(); + parser.parse('NODE_ENV=production'); + + t.alike(parser.getEnv(), { + NODE_ENV: 'production', + }); +}); + +test('EnvParser should parse multiple comma-separated values', function (t) { + const parser = new EnvParser(); + parser.parse('NODE_ENV=production,DEBUG=true,PORT=3000'); + + t.alike(parser.getEnv(), { + NODE_ENV: 'production', + DEBUG: 'true', + PORT: '3000', + }); +}); + +test('EnvParser should merge multiple parse calls', function (t) { + const parser = new EnvParser(); + parser.parse('NODE_ENV=production'); + parser.parse('DEBUG=true'); + + t.alike(parser.getEnv(), { + NODE_ENV: 'production', + DEBUG: 'true', + }); +}); + +test('EnvParser should inherit existing environment variables', function (t) { + const parser = new EnvParser(); + + // Set up some test environment variables + process.env.TEST_VAR1 = 'value1'; + process.env.TEST_VAR2 = 'value2'; + + parser.parse('TEST_VAR1,TEST_VAR2'); + + t.alike(parser.getEnv(), { + TEST_VAR1: 'value1', + TEST_VAR2: 'value2', + }); + + // Cleanup + delete process.env.TEST_VAR1; + delete process.env.TEST_VAR2; +}); + +test('EnvParser should handle mixed inheritance and setting', function (t) { + const parser = new EnvParser(); + + process.env.TEST_VAR = 'inherited'; + + parser.parse('TEST_VAR,NEW_VAR=set'); + + t.alike(parser.getEnv(), { + TEST_VAR: 'inherited', + NEW_VAR: 'set', + }); + + // Cleanup + delete process.env.TEST_VAR; +}); + +test('EnvParser should handle values with spaces', function (t) { + const parser = new EnvParser(); + parser.parse('MESSAGE=Hello World'); + + t.alike(parser.getEnv(), { + MESSAGE: 'Hello World', + }); +}); + +test('EnvParser should handle values with equals signs', function (t) { + const parser = new EnvParser(); + parser.parse('DATABASE_URL=postgres://user:pass@localhost:5432/db'); + + t.alike(parser.getEnv(), { + DATABASE_URL: 'postgres://user:pass@localhost:5432/db', + }); +}); + +test('EnvParser should handle empty values', function (t) { + const parser = new EnvParser(); + parser.parse('EMPTY='); + + t.alike(parser.getEnv(), { + EMPTY: '', + }); +}); + +test('EnvParser should handle whitespace', function (t) { + const parser = new EnvParser(); + parser.parse(' KEY = value with spaces '); + + t.alike(parser.getEnv(), { + KEY: ' value with spaces', // Leading whitespace preserved, trailing removed + }); + + // Test multiple values with whitespace + parser.parse(' KEY2 = value2 , KEY3 = value3 '); + + t.alike(parser.getEnv(), { + KEY: ' value with spaces', + KEY2: ' value2', + KEY3: ' value3', + }); +}); + +test('EnvParser should merge and override values', function (t) { + const parser = new EnvParser(); + parser.parse('KEY=first'); + parser.parse('KEY=second'); + + t.alike(parser.getEnv(), { + KEY: 'second', + }); +}); + +test('EnvParser should throw on missing equal sign', function (t) { + const parser = new EnvParser(); + + t.exception( + () => parser.parse('INVALID_FORMAT'), + 'Invalid environment variable format: INVALID_FORMAT\nMust be in format KEY=VALUE', + ); +}); + +test('EnvParser should throw on empty key', function (t) { + const parser = new EnvParser(); + + t.exception( + () => parser.parse('=value'), + 'Invalid environment variable format: =value\nMust be in format KEY=VALUE', + ); +}); + +test('EnvParser should handle empty constructor', function (t) { + const parser = new EnvParser(); + + t.alike(parser.getEnv(), {}); +}); + +test('EnvParser should handle multiple commas and whitespace', function (t) { + const parser = new EnvParser(); + parser.parse('KEY1=value1, KEY2=value2,,,KEY3=value3'); + + t.alike(parser.getEnv(), { + KEY1: 'value1', + KEY2: 'value2', + KEY3: 'value3', + }); +}); + +test('EnvParser should handle values containing escaped characters', function (t) { + const parser = new EnvParser(); + + // This is how Node.js argv will receive it after shell processing + parser.parse('A=VERBATIM CONTENTS\\, GO HERE'); // Users will type: --env 'A=VERBATIM CONTENTS\, GO HERE' + + t.alike(parser.getEnv(), { + A: 'VERBATIM CONTENTS, GO HERE', // Comma should be unescaped in final value + }); +}); diff --git a/integration-tests/cli/help.test.js b/integration-tests/cli/help.test.js deleted file mode 100644 index 001c379ade..0000000000 --- a/integration-tests/cli/help.test.js +++ /dev/null @@ -1,72 +0,0 @@ -import test from 'brittle'; -import { getBinPath } from 'get-bin-path'; -import { prepareEnvironment } from '@jakechampion/cli-testing-library'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const packageJson = readFileSync(join(__dirname, '../../package.json'), { - encoding: 'utf-8', -}); -const version = JSON.parse(packageJson).version; - -const cli = await getBinPath({ name: 'js-compute' }); - -test('--help should return help on stdout and zero exit code', async function (t) { - const { execute, cleanup } = await prepareEnvironment(); - t.teardown(async function () { - await cleanup(); - }); - const { code, stdout, stderr } = await execute('node', `${cli} --help`); - - t.is(code, 0); - t.alike(stdout, [ - `js-compute-runtime-cli.js ${version}`, - 'USAGE:', - 'js-compute-runtime-cli.js [FLAGS] [OPTIONS] [ARGS]', - 'FLAGS:', - '-h, --help Prints help information', - '-V, --version Prints version information', - 'OPTIONS:', - '--engine-wasm The JS engine Wasm file path', - '--module-mode [experimental] Run all sources as native modules,', - 'with full error stack support.', - '--enable-aot Enable AOT compilation for performance', - '--enable-experimental-high-resolution-time-methods Enable experimental high-resolution fastly.now() method', - '--enable-experimental-top-level-await Enable experimental top level await', - 'ARGS:', - " The input JS script's file path [default: bin/index.js]", - ' The file path to write the output Wasm module to [default: bin/main.wasm]', - ]); - t.alike(stderr, []); -}); - -test('-h should return help on stdout and zero exit code', async function (t) { - const { execute, cleanup } = await prepareEnvironment(); - t.teardown(async function () { - await cleanup(); - }); - const { code, stdout, stderr } = await execute('node', `${cli} -h`); - - t.is(code, 0); - t.alike(stdout, [ - `js-compute-runtime-cli.js ${version}`, - 'USAGE:', - 'js-compute-runtime-cli.js [FLAGS] [OPTIONS] [ARGS]', - 'FLAGS:', - '-h, --help Prints help information', - '-V, --version Prints version information', - 'OPTIONS:', - '--engine-wasm The JS engine Wasm file path', - '--module-mode [experimental] Run all sources as native modules,', - 'with full error stack support.', - '--enable-aot Enable AOT compilation for performance', - '--enable-experimental-high-resolution-time-methods Enable experimental high-resolution fastly.now() method', - '--enable-experimental-top-level-await Enable experimental top level await', - 'ARGS:', - " The input JS script's file path [default: bin/index.js]", - ' The file path to write the output Wasm module to [default: bin/main.wasm]', - ]); - t.alike(stderr, []); -}); diff --git a/integration-tests/js-compute/fixtures/app/fastly.toml.in b/integration-tests/js-compute/fixtures/app/fastly.toml.in index 79e9855af5..715c15336e 100644 --- a/integration-tests/js-compute/fixtures/app/fastly.toml.in +++ b/integration-tests/js-compute/fixtures/app/fastly.toml.in @@ -9,7 +9,7 @@ name = "js-test-app" service_id = "" [scripts] - build = "node ../../../../js-compute-runtime-cli.js --enable-experimental-high-resolution-time-methods src/index.js" + build = "node ../../../../js-compute-runtime-cli.js --env LOCAL_TEST,TEST=\"foo\" --enable-experimental-high-resolution-time-methods src/index.js" [local_server] diff --git a/integration-tests/js-compute/fixtures/app/src/env.js b/integration-tests/js-compute/fixtures/app/src/env.js index 7891527c45..a016861b9a 100644 --- a/integration-tests/js-compute/fixtures/app/src/env.js +++ b/integration-tests/js-compute/fixtures/app/src/env.js @@ -1,14 +1,28 @@ /* eslint-env serviceworker */ import { env } from 'fastly:env'; import { routes, isRunningLocally } from './routes.js'; -import { assert } from './assertions.js'; +import { strictEqual } from './assertions.js'; + +// hostname didn't exist at initialization, so can still be captured at runtime +const wizerHostname = env('FASTLY_HOSTNAME'); +const wizerLocal = env('LOCAL_TEST'); routes.set('/env', () => { + strictEqual(wizerHostname, undefined); + if (isRunningLocally()) { - assert( + strictEqual( env('FASTLY_HOSTNAME'), 'localhost', `env("FASTLY_HOSTNAME") === "localhost"`, ); + } else { + strictEqual(env('FASTLY_HOSTNAME'), undefined); } + + strictEqual(wizerLocal, 'local val'); + + // at runtime these remain captured from Wizer time, even if we didn't call env + strictEqual(env('LOCAL_TEST'), 'local val'); + strictEqual(env('TEST'), 'foo'); }); diff --git a/integration-tests/js-compute/test.js b/integration-tests/js-compute/test.js index a2b1516ec2..9989363a85 100755 --- a/integration-tests/js-compute/test.js +++ b/integration-tests/js-compute/test.js @@ -11,6 +11,9 @@ import { copyFile, readFile, writeFile } from 'node:fs/promises'; import core from '@actions/core'; import TOML from '@iarna/toml'; +// test environment variable handling +process.env.LOCAL_TEST = 'local val'; + async function killPortProcess(port) { zx.verbose = false; const pids = (await zx`lsof -ti:${port}`).stdout; diff --git a/js-compute-runtime-cli.js b/js-compute-runtime-cli.js index aa0f4c34b7..ac03ecd8c6 100755 --- a/js-compute-runtime-cli.js +++ b/js-compute-runtime-cli.js @@ -16,6 +16,7 @@ const { output, version, help, + env, } = await parseInputs(process.argv.slice(2)); if (version) { @@ -41,6 +42,7 @@ if (version) { aotCache, moduleMode, bundle, + env, ); await addSdkMetadataField(output, enableAOT); } diff --git a/runtime/fastly/builtins/fastly.cpp b/runtime/fastly/builtins/fastly.cpp index 9269e86060..856febbcf2 100644 --- a/runtime/fastly/builtins/fastly.cpp +++ b/runtime/fastly/builtins/fastly.cpp @@ -22,12 +22,17 @@ using fastly::fetch::RequestOrResponse; using fastly::fetch::Response; using fastly::logger::Logger; +extern char **environ; + namespace { bool DEBUG_LOGGING_ENABLED = false; api::Engine *ENGINE; +// Global storage for Wizer-time environment +std::unordered_map initialized_env; + static void oom_callback(JSContext *cx, void *data) { fprintf(stderr, "Critical Error: out of memory\n"); fflush(stderr); @@ -319,15 +324,42 @@ bool Env::env_get(JSContext *cx, unsigned argc, JS::Value *vp) { if (!args.requireAtLeast(cx, "fastly.env.get", 1)) return false; - auto var_name_chars = core::encode(cx, args[0]); - if (!var_name_chars) { + JS::RootedString str(cx, JS::ToString(cx, args[0])); + if (!str) { return false; } - JS::RootedString env_var(cx, JS_NewStringCopyZ(cx, getenv(var_name_chars.begin()))); - if (!env_var) + + JS::UniqueChars ptr = JS_EncodeStringToUTF8(cx, str); + if (!ptr) { return false; + } + + // This shouldn't fail, since the encode operation ensured `str` is linear. + JSLinearString *linear = JS_EnsureLinearString(cx, str); + uint32_t len = JS::GetDeflatedUTF8StringLength(linear); + + std::string key_str(ptr.get(), len); + + // First check initialized environment + if (auto it = initialized_env.find(key_str); it != initialized_env.end()) { + JS::RootedString env_var(cx, JS_NewStringCopyN(cx, it->second.data(), it->second.size())); + if (!env_var) + return false; + args.rval().setString(env_var); + return true; + } - args.rval().setString(env_var); + // Fallback to getenv with caching + if (const char *value = std::getenv(key_str.c_str())) { + auto [it, _] = initialized_env.emplace(key_str, value); + JS::RootedString env_var(cx, JS_NewStringCopyN(cx, it->second.data(), it->second.size())); + if (!env_var) + return false; + args.rval().setString(env_var); + return true; + } + + args.rval().setUndefined(); return true; } @@ -475,6 +507,19 @@ bool install(api::Engine *engine) { } // fastly:env + // first, store the initialized environment vars from Wizer + initialized_env.clear(); + + for (char **env = environ; *env; env++) { + const char *entry = *env; + const char *eq = entry; + while (*eq && *eq != '=') + eq++; + + if (*eq == '=') { + initialized_env.emplace(std::string(entry, eq - entry), std::string(eq + 1)); + } + } RootedValue env_get(engine->cx()); if (!JS_GetProperty(engine->cx(), Fastly::env, "get", &env_get)) { return false; diff --git a/src/compileApplicationToWasm.js b/src/compileApplicationToWasm.js index d0cccc833e..9ed530c3f9 100644 --- a/src/compileApplicationToWasm.js +++ b/src/compileApplicationToWasm.js @@ -30,6 +30,7 @@ export async function compileApplicationToWasm( aotCache = '', moduleMode = false, doBundle = false, + env, ) { try { if (!(await isFile(input))) { @@ -128,6 +129,7 @@ export async function compileApplicationToWasm( shell: true, encoding: 'utf-8', env: { + ...env, ENABLE_EXPERIMENTAL_HIGH_RESOLUTION_TIME_METHODS: enableExperimentalHighResolutionTimeMethods ? '1' : '0', }, @@ -176,7 +178,7 @@ export async function compileApplicationToWasm( shell: true, encoding: 'utf-8', env: { - ...process.env, + ...env, ENABLE_EXPERIMENTAL_HIGH_RESOLUTION_TIME_METHODS: enableExperimentalHighResolutionTimeMethods ? '1' : '0', }, diff --git a/src/env.js b/src/env.js new file mode 100644 index 0000000000..0282ddcd92 --- /dev/null +++ b/src/env.js @@ -0,0 +1,81 @@ +// env.js +function parseEnvPair(pair) { + const trimmedPair = pair.trim(); + + // If no '=', treat as a variable to inherit + if (!trimmedPair.includes('=')) { + const value = process.env[trimmedPair]; + if (value === undefined) { + throw new Error(`Environment variable ${trimmedPair} is not defined`); + } + console.warn( + `Writing ${trimmedPair} environment variable into the runtime from the current process environment`, + ); + return [trimmedPair, value]; + } + + const matches = trimmedPair.match(/^([^=]+)=(.*)$/); + if (!matches) { + throw new Error( + `Invalid environment variable format: ${trimmedPair}\nMust be in format KEY=VALUE or an existing environment variable name`, + ); + } + + const key = matches[1].trim(); + const value = matches[2]; + + if (!key) { + throw new Error( + `Invalid environment variable format: ${trimmedPair}\nMust be in format KEY=VALUE or an existing environment variable name`, + ); + } + + return [key, value]; +} + +function parseEnvString(envString) { + const result = {}; + + // Split on unescaped commas and filter out empty strings + const pairs = envString + .split(/(? s.replace(/\\,/g, ',')) // Replace escaped commas with regular commas + .filter(Boolean); + + // Parse each pair into the result object + for (const pair of pairs) { + const [key, value] = parseEnvPair(pair); + result[key] = value; + } + + return result; +} + +export class EnvParser { + constructor() { + this.env = {}; + } + + /** + * Parse environment variables string, which can be either KEY=VALUE pairs + * or names of environment variables to inherit + * @param {string} value - The environment variable string to parse + */ + parse(value) { + if (!value) { + throw new Error( + 'Invalid environment variable format: \nMust be in format KEY=VALUE or an existing environment variable name', + ); + } + const newEnv = parseEnvString(value); + this.env = { ...this.env, ...newEnv }; + } + + /** + * Get the parsed environment variables + * @returns {Object} The environment variables object + */ + getEnv() { + return this.env; + } +} diff --git a/src/parseInputs.js b/src/parseInputs.js index c5e674196d..f27c4d2347 100644 --- a/src/parseInputs.js +++ b/src/parseInputs.js @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join, isAbsolute } from 'node:path'; import { unknownArgument } from './unknownArgument.js'; import { tooManyEngines } from './tooManyEngines.js'; +import { EnvParser } from './env.js'; export async function parseInputs(cliInputs) { const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -19,12 +20,31 @@ export async function parseInputs(cliInputs) { let output = join(process.cwd(), 'bin/main.wasm'); let cliInput; + const envParser = new EnvParser(); + // eslint-disable-next-line no-cond-assign loop: while ((cliInput = cliInputs.shift())) { switch (cliInput) { case '--': { break loop; } + case '--env': { + const value = cliInputs.shift(); + if (!value) { + console.error('Error: --env requires a KEY=VALUE pair'); + process.exit(1); + } + // If value ends with comma, it's a continuation + while ( + value.endsWith(',') && + cliInputs.length > 0 && + !cliInputs[0].startsWith('-') + ) { + value = value + cliInputs.shift(); + } + envParser.parse(value); + break; + } case '--enable-experimental-high-resolution-time-methods': { enableExperimentalHighResolutionTimeMethods = true; break; @@ -87,6 +107,7 @@ export async function parseInputs(cliInputs) { break; } case '--aot-cache': { + const value = cliInputs.shift(); if (isAbsolute(value)) { aotCache = value; } else { @@ -95,16 +116,11 @@ export async function parseInputs(cliInputs) { break; } default: { - // The reason this is not another `case` and is an `if` using `startsWith` - // is because previous versions of the CLI allowed an arbitrary amount of - // = characters to be present. E.G. This is valid --engine-wasm====js.wasm if (cliInput.startsWith('--engine-wasm=')) { if (customEngineSet) { tooManyEngines(); } const value = cliInput.replace(/--engine-wasm=+/, ''); - // This is used to detect if multiple --engine-wasm flags have been set - // which is not supported. customEngineSet = true; if (isAbsolute(value)) { wasmEngine = value; @@ -112,6 +128,10 @@ export async function parseInputs(cliInputs) { wasmEngine = join(process.cwd(), value); } break; + } else if (cliInput.startsWith('--env=')) { + const value = cliInput.replace(/--env=/, ''); + envParser.parse(value); + break; } else if (cliInput.startsWith('-')) { unknownArgument(cliInput); } else { @@ -150,5 +170,6 @@ export async function parseInputs(cliInputs) { input, output, wasmEngine, + env: envParser.getEnv(), }; } diff --git a/src/printHelp.js b/src/printHelp.js index 3e35cb6b3b..eeb8fe97cd 100644 --- a/src/printHelp.js +++ b/src/printHelp.js @@ -13,11 +13,15 @@ FLAGS: -V, --version Prints version information OPTIONS: - --engine-wasm The JS engine Wasm file path + --env Set environment variables, possibly inheriting + from the current environment. Multiple + variables can be comma-separated + (e.g., --env ENV_VAR,OVERRIDE=val) --module-mode [experimental] Run all sources as native modules, - with full error stack support. + with full error stack support. + --engine-wasm The JS engine Wasm file path --enable-aot Enable AOT compilation for performance - --enable-experimental-high-resolution-time-methods Enable experimental high-resolution fastly.now() method + --enable-experimental-high-resolution-time-methods Enable experimental fastly.now() method --enable-experimental-top-level-await Enable experimental top level await ARGS: