Skip to content

Commit

Permalink
feat: JavaScript file configuration of apps
Browse files Browse the repository at this point in the history
This adds the ability to configure an application via an env.config.js file rather than environment variables or .env files.  This mechanism is much more flexible and powerful than environment variables as described in the associated ADR.

It also improves documentation around how to configure applications, providing more detail on the various methods.

Finally, it allows the logging, analytics, and auth services to be configured via the configuration document now that we can supply an alternate implementation in an env.config.js file.  This allows configuration of these services without forking the MFE.
  • Loading branch information
davidjoy committed Apr 11, 2023
1 parent 7c53d4d commit 6507625
Show file tree
Hide file tree
Showing 7 changed files with 277 additions and 22 deletions.
111 changes: 111 additions & 0 deletions docs/decisions/0007-javascript-file-configuration.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
Promote JavaScript file configuration and deprecate environment variable configuration
======================================================================================

Status
------

Draft

Context
-------

Our webpack build process allows us to set environment variables on the command line or via .env
files. These environment variables are available in the application via ``process.env``.

The implementation of this uses templatization and string interpolation to replace any instance of
``process.env.XXXX`` with the value of the environment variable named ``XXXX``. As an example, in our
source code we may write::

const LMS_BASE_URL = process.env.LMS_BASE_URL;

After the build process runs, the compiled source code will instead read::

const LMS_BASE_URL = 'http://localhost:18000';

Put another way, `process.env` is not actually an object available at runtime, it's a templatization
token that helps the build replace it with a string literal.

This approach has several important limitations:

- There's no way to add variables without hard-coding process.env.XXXX somewhere in the file,
complicating our ability to add additional application-specific configuration without explici tly
merging it into the configuration document after it's been created in frontend-platform.
- The method can *only* handle strings.

Other data types are converted to strings::

# Build command:
BOOLEAN_VAR=false NULL_VAR=null NUMBER_VAR=123 npm run build

...

// Source code:
const BOOLEAN_VAR = process.env.BOOLEAN_VAR;
const NULL_VAR = process.env.NULL_VAR;
const NUMBER_VAR = process.env.NUMBER_VAR;

...

// Compiled source after the build runs:
const BOOLEAN_VAR = "false";
const NULL_VAR = "null";
const NUMBER_VAR = "123";

This is not good!

- It makes it very difficult to supply array and object configuration variables, and unreasonable to
supply class or function config since we'd have to ``eval()`` them.

Related to all this, frontend-platform has long hand the ability to replace the implementations of
its analytics, auth, and logging services, but no way to actually *configure* the app with a new
implementation. Because of the above limitations, there's no reasonable way to configure a
JavaScript class via environment variables.

Decision
--------

For the above reasons, we will deprecate environment variable configuration in favor of JavaScript
file configuration.

This method makes use of an ``env.config.js`` file to supply configuration variables to an application::

const config = {
LMS_BASE_URL: 'http://localhost:18000',
BOOLEAN_VAR: false,
NULL_VAR: null,
NUMBER_VAR: 123
};

export default config;

This file is imported by the frontend-build webpack build process if it exists, and expected by
frontend-platform as part of its initialization process. If the file doesn't exist, frontend-build
falls back to importing an empty object for backwards compatibility. This functionality already exists
today in frontend-build in preparation for using it here in frontend-platform.

This interdependency creates a peerDependency for frontend-platform on `frontend-build v8.1.0 <frontend_build_810_>`_ or
later.

Using a JavaScript file for configuration is standard practice in the JavaScript/node community.
Babel, webpack, eslint, Jest, etc., all accept configuration via JavaScript files (which we take
advantage of in frontend-build), so there is ample precedent for using a .js file for configuration.

In order to achieve deprecation of environment variable configuration, we will follow the
deprecation process described in `OEP-21: Deprecation and Removal <oep21_>`_. In addition, we will
add build-time warnings to frontend-build indicating the deprecation of environment
variable configuration. Practically speaking, this will mean adjusting build processes throughout
the community and in common tools like Tutor.

Rejected Alternatives
---------------------

Another option was to use JSON files for this purpose. This solves some of our issues (limited use
of non-string primitive data types) but is otherwise not nearly as expressive for flexible as using
a JavaScript file directly. Anecdotally, in the past frontend-build used JSON versions of many of
its configuration files (Babel, eslint, jest) but over time they were all converted to JavaScript
files so we could express more complicated configuration needs. Since one of the primary use cases
and reasons we need a new configuration method is to allow developers to supply alternate implementations
of frontend-platform's core services (analytics, logging), JSON was a effectively a non-starter.

.. _oep21: https://docs.openedx.org/projects/openedx-proposals/en/latest/processes/oep-0021-proc-deprecation.html
.. _frontend_build_810: https://github.com/openedx/frontend-build/releases/tag/v8.1.0
8 changes: 8 additions & 0 deletions env.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// NOTE: This file is used by the example app and the test suite. frontend-build expects the file
// to be in the root of the repository. This is not used by the actual frontend-platform library.
// Also note that in an actual application this file would be added to .gitignore.
const config = {
JS_FILE_VAR: 'JS_FILE_VAR_VALUE',
};

export default config;
2 changes: 2 additions & 0 deletions example/ExamplePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mergeConfig({

ensureConfig([
'EXAMPLE_VAR',
'JS_FILE_VAR',
], 'ExamplePage');

class ExamplePage extends Component {
Expand Down Expand Up @@ -45,6 +46,7 @@ class ExamplePage extends Component {
<p>{this.props.intl.formatMessage(messages['example.message'])}</p>
{this.renderAuthenticatedUser()}
<p>EXAMPLE_VAR env var came through: <strong>{getConfig().EXAMPLE_VAR}</strong></p>
<p>JS_FILE_VAR var came through: <strong>{getConfig().JS_FILE_VAR}</strong></p>
<p>Visit <Link to="/authenticated">authenticated page</Link>.</p>
<p>Visit <Link to="/error_example">error page</Link>.</p>
</div>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
},
"peerDependencies": {
"@edx/paragon": ">= 10.0.0 < 21.0.0",
"@edx/frontend-build": ">= 8.1.0",
"prop-types": "^15.7.2",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0",
Expand Down
122 changes: 105 additions & 17 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,89 @@
* #### Import members from **@edx/frontend-platform**
*
* The configuration module provides utilities for working with an application's configuration
* document (ConfigDocument). This module uses `process.env` to import configuration variables
* from the command-line build process. It can be dynamically extended at run-time using a
* `config` initialization handler. Please see the Initialization documentation for more
* information on handlers and initialization phases.
* document (ConfigDocument). Configuration environment variables can be supplied to the
* application in four different ways:
*
* ##### Build-time Environment Variables
*
* A set list of required config variables can be supplied as
* command-line environment variables during the build process.
*
* As a simple example, these are supplied on the command-line before invoking `npm run build`:
*
* ```
* import { getConfig } from '@edx/frontend-platform';
* LMS_BASE_URL=http://localhost:18000 npm run build
* ```
*
* const {
* BASE_URL,
* LMS_BASE_URL,
* LOGIN_URL,
* LOGIN_URL,
* REFRESH_ACCESS_TOKEN_ENDPOINT,
* ACCESS_TOKEN_COOKIE_NAME,
* CSRF_TOKEN_API_PATH,
* } = getConfig();
* Note that additional variables _cannot_ be supplied via this method without using the `config`
* initialization handler. The app won't pick them up and they'll appear `undefined`.
*
* ##### JavaScript File Configuration
*
* Configuration variables can be supplied in an optional file
* named env.config.js. This file must export either an Object containing configuration variables
* or a function. The function must return an Object containing configuration variables or,
* alternately, a promise which resolves to an Object.
*
* Exporting a config object:
* ```
* const config = {
* LMS_BASE_URL: 'http://localhost:18000'
* };
*
* export default config;
* ```
*
* Exporting a function that returns an object:
* ```
* function getConfig() {
* return {
* LMS_BASE_URL: 'http://localhost:18000'
* };
* }
* ```
*
* Exporting a function that returns a promise that resolves to an object:
* ```
* function getAsyncConfig() {
* return new Promise((resolve, reject) => {
* resolve({
* LMS_BASE_URL: 'http://localhost:18000'
* });
* });
* }
*
* export default getAsyncConfig;
* ```
*
* ##### Runtime Configuration
*
* Configuration variables can also be supplied using the "runtime
* configuration" method, taking advantage of the Micro-frontend Config API in edx-platform.
* More information on this API can be found in the ADR which introduced it:
*
* https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/mfe_config_api/docs/decisions/0001-mfe-config-api.rst
*
* The runtime configuration method can be enabled by supplying a MFE_CONFIG_API_URL via one of the other
* two configuration methods above.
*
* ##### Initialization Config Handler
*
* The configuration document can be extended by
* applications at run-time using a `config` initialization handler. Please see the Initialization
* documentation for more information on handlers and initialization phases.
*
* ```
* initialize({
* handlers: {
* config: () => {
* mergeConfig({
* CUSTOM_VARIABLE: 'custom value',
* LMS_BASE_URL: 'http://localhost:18001' // You can override variables, but this is uncommon.
* }, 'App config override handler');
* },
* },
* });
* ```
*
* @module Config
Expand Down Expand Up @@ -76,8 +142,17 @@ let config = {

/**
* Getter for the application configuration document. This is synchronous and merely returns a
* reference to an existing object, and is thus safe to call as often as desired. The document
* should have the following keys at a minimum:
* reference to an existing object, and is thus safe to call as often as desired.
*
* Example:
*
* ```
* import { getConfig } from '@edx/frontend-platform';
*
* const {
* LMS_BASE_URL,
* } = getConfig();
* ```
*
* @returns {ConfigDocument}
*/
Expand All @@ -91,6 +166,16 @@ export function getConfig() {
* The supplied config document will be tested with `ensureDefinedConfig` to ensure it does not
* have any `undefined` keys.
*
* Example:
*
* ```
* import { setConfig } from '@edx/frontend-platform';
*
* setConfig({
* LMS_BASE_URL, // This is overriding the ENTIRE document - this is not merged in!
* });
* ```
*
* @param {ConfigDocument} newConfig
*/
export function setConfig(newConfig) {
Expand Down Expand Up @@ -157,7 +242,10 @@ export function ensureConfig(keys, requester = 'unspecified application code') {
/**
* An object describing the current application configuration.
*
* The implementation loads this document via `process.env` variables.
* In its most basic form, the initialization process loads this document via `process.env`
* variables. There are other ways to add configuration variables to the ConfigDocument as
* documented above (JavaScript File Configuration, Runtime Configuration, and the Initialization
* Config Handler)
*
* ```
* {
Expand Down
48 changes: 43 additions & 5 deletions src/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@
*/

import { createBrowserHistory, createMemoryHistory } from 'history';
/*
This 'env.config' package is a special 'magic' alias in our webpack configuration in frontend-build.
It points at an `env.config.js` file in the root of an MFE's repository if it exists and falls back
to an empty object `{}` if the file doesn't exist. This acts like an 'optional' import, in a sense.
Note that the env.config.js file in frontend-platform's root directory is NOT used by the actual
initialization code, it's just there for the test suite and example application.
*/
import envConfig from 'env.config'; // eslint-disable-line import/no-unresolved

import {
publish,
} from './pubSub';
Expand Down Expand Up @@ -130,13 +139,32 @@ export async function auth(requireUser, hydrateUser) {
hydrateAuthenticatedUser();
}
}

/**
* Set or overrides configuration via an env.config.js file in the consuming application.
* This env.config.js is loaded at runtime and must export one of two things:
*
* - An object which will be merged into the application config via `mergeConfig`.
* - A function which returns an object which will be merged into the application config via
* `mergeConfig`. This function can return a promise.
*/
async function jsFileConfig() {
let config = {};
if (typeof envConfig === 'function') {
config = await envConfig();
} else {
config = envConfig;
}

mergeConfig(config);
}

/*
* Set or overrides configuration through an API.
* This method allows runtime configuration.
* Set a basic configuration when an error happen and allow initError and display the ErrorPage.
*/

export async function runtimeConfig() {
async function runtimeConfig() {
try {
const { MFE_CONFIG_API_URL, APP_ID } = getConfig();

Expand Down Expand Up @@ -253,18 +281,28 @@ export async function initialize({

// Configuration
await handlers.config();
await jsFileConfig();
await runtimeConfig();
publish(APP_CONFIG_INITIALIZED);

// This allows us to replace the implementations of the logging, analytics, and auth services
// based on keys in the ConfigDocument. The JavaScript File Configuration method is the only
// one capable of supplying an alternate implementation since it can import other modules.
// If a service wasn't supplied we fall back to the default parameters on the initialize
// function signature.
const loggingServiceImpl = getConfig().loggingService || loggingService;
const analyticsServiceImpl = getConfig().analyticsService || analyticsService;
const authServiceImpl = getConfig().authService || authService;

// Logging
configureLogging(loggingService, {
configureLogging(loggingServiceImpl, {
config: getConfig(),
});
await handlers.logging();
publish(APP_LOGGING_INITIALIZED);

// Authentication
configureAuth(authService, {
configureAuth(authServiceImpl, {
loggingService: getLoggingService(),
config: getConfig(),
middleware: authMiddleware,
Expand All @@ -274,7 +312,7 @@ export async function initialize({
publish(APP_AUTH_INITIALIZED);

// Analytics
configureAnalytics(analyticsService, {
configureAnalytics(analyticsServiceImpl, {
config: getConfig(),
loggingService: getLoggingService(),
httpClient: getAuthenticatedHttpClient(),
Expand Down
Loading

0 comments on commit 6507625

Please sign in to comment.