Patrick Desjardins Blog
Patrick Desjardins picture from a conference

Typescript Web Project and Environment Variables

Posted on: 2022-01-26

Many projects require to have configurations. These configurations change depending on the environment. For example, you can have a different service DNS address and port to connect depending if you are developing locally or in a testing environment, or in production. A typical pattern is to set variables in a .env file. Many systems like NodeJS or Docker can easily read the file. However, web projects created with a web framework struggle to read the .env file. ReactJS and SolidJS are two popular frameworks that have a workaround. They inject the value of specific prefixed variables from the .env file into the code. The constraint of the prefix allows having at build time values from the file into the generated JavaScript to bypass the limitation that the browser does not have access to the .env file on the server. The issue with this workaround is that it forces the usage of variables prefixed (React Doc, SolidJS Doc). What if we want to use a .env file in a mono repository that shares a variable between projects or do not wish to use a prefixed variable between projects?

A small solution is to create your custom script that opens the .env file and generates a variable that can be referenced directly in your codebase. It means that Docker and other systems can rely on the .env file, using all the variable names you desired and at the same time having the web project, React framework or else to also use the same variable names -- no duplication or name constraint.

Package.json

The first modification is to ensure that the script is running when you start your application. You want to ensure the script generate the TypeScript (or JavaScript) file that contains all the value. With the generated file in place, you can import the variable and use the values in your web project.

Here is an example of a SolidJS project that relies on Vite.

  "scripts": {
    "start:production": "npm run copy:env && vite --host 0.0.0.0",
    "start:development": "npm run copy:env && vite --host 0.0.0.0",
    "build": "npm run copy:env && vite build",
    "serve": "npm run copy:env && vite preview",
    "copy:env": "python ../scripts/envToConsts.py"
  },

As you can see, all commands start with npm run copy:env

Python Script

I decided to rely on Python for the script. It is a shortcode that relies on opening a file, reading the lines, and saving the created variable into a TypeScript file.

Here is the complete script that works even if there are comments inside the .env file.

import os

# Read the environment variables from the .env file
__location__ = os.path.realpath(os.path.join(
    os.getcwd(), os.path.dirname(__file__)))
f = open(os.path.join(__location__, '../.env'))
lines = f.read().splitlines()

# Extract all the key-value of each line
keyValues = []
for line in lines:
    if line.find("=") >= 0 and line.strip().find("#") != 0:
        lineSplit = line.split("=")
        left = lineSplit[0].strip()
        right = lineSplit[1].strip()
        if right.find("#") > 0:
            right = right[:right.find("#")-1]
        keyValues.append([left, right])

# Create the TS file
tsFile = "/**\n ** This file is generated by scripts/envToConsts.py\n **/\n"
tsFile += "export const ENV_VARIABLES = {\n"

for pair in keyValues:
    if unicode(pair[1], "utf-8").isnumeric():
        tsFile += "  " + pair[0] + ": " + pair[1] + ",\n"
    elif pair[1] == "true" or pair[1] == "false" or pair[1] == "TRUE" or pair[1] == "FALSE":
        tsFile += "  " + pair[0] + ": " + pair[1].lower() + ",\n"
    else:
        tsFile += "  " + pair[0] + ": \"" + pair[1].lower() + "\",\n"
tsFile += "}"

# Write the file on disk
f = open(os.path.join(__location__,
         '../app/src/generated/constants_env.ts'), "w")
f.write(tsFile)
f.close()

The input file can look like this:

# Which environment is running? It should be "development" or "production".
NODE_ENV=development

CLIENT_IP=127.0.0.1
CLIENT_PORT=3000 # Need to be the same as the value set in vite.config.ts and nginx.conf
DOCKER_CLIENT_PORT_FORWARD=3501 # Port opened by the Docker to communicate to the client
DOCKER_CLIENT_HEALTHCHECK_TEST=/bin/true 

SERVER_IP=127.0.0.1
SERVER_PORT=80 # Internal port for the Node server
DOCKER_SERVER_PORT_FORWARD=3500 # Port opened by the Docker to communicate to the server

# What health check test command do you want to run? In development, having it
# curl your web server will result in a lot of log spam, so setting it to
# /bin/true is an easy way to make the health check do basically nothing.
#export DOCKER_SERVER_HEALTHCHECK_TEST=curl localhost:8700/health
DOCKER_SERVER_HEALTHCHECK_TEST=/bin/true 

and the produced TypeScript file looks like:

/**
 ** This file is generated by scripts/envToConsts.py
 **/
export const ENV_VARIABLES = {
  NODE_ENV: "development",
  CLIENT_IP: "127.0.0.1",
  CLIENT_PORT: 3000,
  DOCKER_CLIENT_PORT_FORWARD: 3501,
  DOCKER_CLIENT_HEALTHCHECK_TEST: "/bin/true",
  SERVER_IP: "127.0.0.1",
  SERVER_PORT: 80,
  DOCKER_SERVER_PORT_FORWARD: 3500,
  DOCKER_SERVER_HEALTHCHECK_TEST: "/bin/true",
}

The script might require some modification for your need. For example, the source path and the output path. In my case, I had to work around Docker and adjust the paths for the Docker's folders structure.

Conclusion

I found the scripting solution to be quite convenient. It allows a central configuration file with clean names. Furthermore, it provides for potential customization like marking configuration to be opt-out for the web project if needed (e.g., using a particular comment to opt-out). In the end, this simple script removed the need to duplicate variables. For example, Docker uses the IP and port for the server in this file and the web project to know where to connect. Without this solution, we would need to have duplicated data (prefixed and non-prefixed) or to have the Docker system uses the prefixed variables.