diff --git a/docs/decisions/0007-javascript-file-configuration.rst b/docs/decisions/0007-javascript-file-configuration.rst new file mode 100644 index 000000000..3dc2377b6 --- /dev/null +++ b/docs/decisions/0007-javascript-file-configuration.rst @@ -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 `_ 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 `_. 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 diff --git a/env.config.js b/env.config.js new file mode 100644 index 000000000..bbbbd9650 --- /dev/null +++ b/env.config.js @@ -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; diff --git a/example/ExamplePage.jsx b/example/ExamplePage.jsx index 838358542..1b513ded3 100644 --- a/example/ExamplePage.jsx +++ b/example/ExamplePage.jsx @@ -13,6 +13,7 @@ mergeConfig({ ensureConfig([ 'EXAMPLE_VAR', + 'JS_FILE_VAR', ], 'ExamplePage'); class ExamplePage extends Component { @@ -45,6 +46,7 @@ class ExamplePage extends Component {

{this.props.intl.formatMessage(messages['example.message'])}

{this.renderAuthenticatedUser()}

EXAMPLE_VAR env var came through: {getConfig().EXAMPLE_VAR}

+

JS_FILE_VAR var came through: {getConfig().JS_FILE_VAR}

Visit authenticated page.

Visit error page.

diff --git a/package.json b/package.json index 615ea654c..c5648e51e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config.js b/src/config.js index 139ac5415..ba8b041a6 100644 --- a/src/config.js +++ b/src/config.js @@ -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 @@ -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} */ @@ -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) { @@ -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) * * ``` * { diff --git a/src/initialize.js b/src/initialize.js index f27fe4d00..0a460de2e 100644 --- a/src/initialize.js +++ b/src/initialize.js @@ -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'; @@ -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(); @@ -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, @@ -274,7 +312,7 @@ export async function initialize({ publish(APP_AUTH_INITIALIZED); // Analytics - configureAnalytics(analyticsService, { + configureAnalytics(analyticsServiceImpl, { config: getConfig(), loggingService: getLoggingService(), httpClient: getAuthenticatedHttpClient(), diff --git a/src/initialize.test.js b/src/initialize.test.js index 70b4f1018..d9be37e9b 100644 --- a/src/initialize.test.js +++ b/src/initialize.test.js @@ -279,6 +279,13 @@ describe('initialize', () => { expect(overrideHandlers.initError).toHaveBeenCalledWith(new Error('uhoh!')); }); + it('should initialize the app with javascript file configuration', async () => { + const messages = { i_am: 'a message' }; + await initialize({ messages }); + + expect(config.JS_FILE_VAR).toEqual('JS_FILE_VAR_VALUE'); + }); + it('should initialize the app with runtime configuration', async () => { config.MFE_CONFIG_API_URL = 'http://localhost:18000/api/mfe/v1/config'; config.APP_ID = 'auth';