Skip to content

Commit

Permalink
feat: support --env option for the runtime build (#1090)
Browse files Browse the repository at this point in the history
  • Loading branch information
guybedford authored Jan 22, 2025
1 parent ee409bd commit e81d252
Show file tree
Hide file tree
Showing 11 changed files with 358 additions and 89 deletions.
169 changes: 169 additions & 0 deletions integration-tests/cli/env.test.js
Original file line number Diff line number Diff line change
@@ -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
});
});
72 changes: 0 additions & 72 deletions integration-tests/cli/help.test.js

This file was deleted.

2 changes: 1 addition & 1 deletion integration-tests/js-compute/fixtures/app/fastly.toml.in
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
18 changes: 16 additions & 2 deletions integration-tests/js-compute/fixtures/app/src/env.js
Original file line number Diff line number Diff line change
@@ -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');
});
3 changes: 3 additions & 0 deletions integration-tests/js-compute/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions js-compute-runtime-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const {
output,
version,
help,
env,
} = await parseInputs(process.argv.slice(2));

if (version) {
Expand All @@ -41,6 +42,7 @@ if (version) {
aotCache,
moduleMode,
bundle,
env,
);
await addSdkMetadataField(output, enableAOT);
}
55 changes: 50 additions & 5 deletions runtime/fastly/builtins/fastly.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string, std::string> initialized_env;

static void oom_callback(JSContext *cx, void *data) {
fprintf(stderr, "Critical Error: out of memory\n");
fflush(stderr);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit e81d252

Please sign in to comment.