Sometimes we would like to make some env vars available in the browser - on the client side.
Obviously we have to be very careful with that, because we don't want to expose any sensitive information.
Anyway, it is often beneficial to just somehow expose some env vars to the browser, so we can use them in our browser code.
After working for few years with different frameworks I've noticed that most of the frameworks have builtin support for that:
The problem with all solutions above is that each of them allows you to expose only during build time. So it means you can't expose anything specific to PROD, STAGING or DEV environment because during build these environments are not available/visible yet.
Once build is done exposed variables becomes part of the build itself - so in other words they are hardcoded in the bundle.
Another problem is that each of framwork seems to allow to expose only env vars prefixed with some specific prefix (e.g. VITE_ or REACT_APP_).
It would be nice to have more flexibility here and be able to pick exactly what we want to expose.
So let's try to find a solution to that.
Solution is based on the fact that for each of these framework we can modify the main HTML file and inject our custom (generated) script exposing env vars to the browser like:
// dist/preprocessed.js file
window.process = {
env: {
"MY_ENV": "value",
"ANOTHER_ENV_VAR": "1.99.2"
}
};This script have to be loaded before our bundled code assembled by given framework during build time, so like (just example):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My app...</title>
</head>
<body>
<script src="/dist/preprocessed.js"></script>
<!-- just make sure to load that before bundled code -->
<script src="/dist/bundle.js"></script>
<!-- generated by our framework Vite or Next.js-->
</body>
</html>And in our code we can use library envprocessor which will pick up window.process.env and provide set of method to work with exported env vars in the browser:
import {
all,
get,
has,
getDefault,
getThrow,
getIntegerThrowInvalid, // equivalent to get
getIntegerDefault,
getIntegerThrow,
} from "envprocessor";
if (has('MY_ENV')) {
console.log(getThrow('MY_ENV'));
} else {
console.log('MY_ENV not found');
}Above is safe to import into our bundled code.
Important thing to understand is that natural point where building/generating dist/preprocessed.js have to happen is just before launching the app in the final environment (e.g. in the container or in the K8S pod).
Normally our pod will be defined with entrypoint in Dockerfile something like:
FROM node:...
... other stuff in Dockefile
ENTRYPOINT ["npm", "start"]
In order to generate dist/preprocessed.js we would need to install npm install envprocessor (not as a devDependencies) and can create shell script in our project like:
# start.sh file
set -e
# this is actually important to forward errors
set -x
# we can also add debug mode (optional)
# that will print every command before executing in the pod log
node node_modules/.bin/envprocessor --mask "^TERM_" dist/preprocessed.js
# add --debug and --verbose flags to command above if you need more details
# then we can just run our app as before
npm run start
Then we can swap our entrypoint in Dockerfile to:
ENTRYPOINT ["sh", "start.sh"]
npm install envprocessor
yarn add envprocessor
# to see help and version
npx envprocessor
npx envprocessor --mask "^(TERM_|USER|HOME)" --verbose --debug \
--enrichModule node_modules/envprocessor/enrich.js var/preprocessed.js var/dist/prep.js
# mask above is just an example, probably you will not want to expose USER env var ...
# that will generate two files: var/preprocessed.js var/dist/prep.js
# and will add some extra env vars by executing custom script
# in this case it is node_modules/envprocessor/enrich.js
# but you can use this one as an template and create your own
# One can name these "added" env vars a "synthetic env vars".
# Because it can be anything you like but those
# will not exist in original backend environment as real pod env vars
# the same way as you don't expose all env vars from the backend,
# here you will have env vars which will only exist in the browser
# you might also provide mask using env var injected into the pod during creation if you wish,
# this way we have to just tell the tool which env var to use to get the mask like:
export MASK="^(TERM_|USER|HOME)"
npx envprocessor --maskEnv "MASK" --verbose --debug var/preprocessed.js
# once you install envprocessor:
npm install envprocessor
# you can use it directly from node_modules
# ESM
node node_modules/.bin/envprocessor
node node_modules/envprocessor/dist/esm/cli.js
# CJS
node node_modules/envprocessor/dist/cjs/cli.cjs
# or register in package.json
{
"scripts": {
"preprocessor": "envprocessor --mask '^(TERM_|USER|HOME)' --verbose --debug --enrichModule node_modules/envprocessor/enrich.js var/preprocessed.js var/dist/prep.js"
}
}
# and then run it (also you can use this style in start.sh above):
npm run preprocessor
# you can also process env vars from .env file using native --env-file or --env-file-if-exists
# try to create .env file
cat <<EEE > .env
ENV_VAR_FROM_DOTENV="from .env file"
EEE
# and then run:
node --env-file=.env node_modules/.bin/envprocessor --mask "^ENV_" --verbose var/from_env.js
There is also way to create custom script where you can extend behaviour of envprocessor the way you like, see examples directory.
web example /envprocessor/examples/index.html loading preprocessed.js into the browser context - just inspect it with chrome developer tools
// ESM
import {
all,
get,
has,
getDefault,
getThrow,
getIntegerThrowInvalid, // equivalent to get
getIntegerDefault,
getIntegerThrow,
} from "envprocessor";
// CJS
const {
all,
get,
has,
getDefault,
getThrow,
getIntegerThrowInvalid, // equivalent to get
getIntegerDefault,
getIntegerThrow,
} = require("envprocessor");
console.log(`get('USER') >${get("USER")}`);