diff --git a/docs/decisions/0007-javascript-file-configuration.rst b/docs/decisions/0007-javascript-file-configuration.rst new file mode 100644 index 000000000..9de225937 --- /dev/null +++ b/docs/decisions/0007-javascript-file-configuration.rst @@ -0,0 +1,143 @@ +Promote JavaScript file configuration and deprecate environment variable configuration +====================================================================================== + +Status +------ + +Accepted + +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 explicitly 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 had 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. + +Relationship to runtime configuration +************************************* + +JavaScript file configuration is compatible with runtime MFE configuration. +frontend-platform loads configuration in a predictable order: + +- environment variable config +- optional handlers (commonly used to merge MFE-specific config in via additional + process.env variables) +- JS file config +- runtime config + +In the end, runtime config wins. That said, JS file config solves some use +cases that runtime config can't solve around extensibility and customization. + +In the future if we deprecate environment variable config, it's likely that +we keep both JS file config and runtime configuration around. JS file config +primarily to handle extensibility, and runtime config for everything else. + +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 or 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 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..f4585f66d --- /dev/null +++ b/env.config.js @@ -0,0 +1,8 @@ +// NOTE: This file is used by the example app. 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_FOR_EXAMPLE_APP', +}; + +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 640df90cf..d86e94db3 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..3adea36dd 100644 --- a/src/config.js +++ b/src/config.js @@ -2,23 +2,122 @@ * #### 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 variables can be supplied to the + * application in four different ways. They are applied in the following order: + * + * - Build-time Configuration + * - Environment Variables + * - JavaScript File + * - Runtime Configuration + * + * Last one in wins. Variables with the same name defined via the later methods will override any + * defined using an earlier method. i.e., if a variable is defined in Runtime Configuration, that + * will override the same variable defined in either Build-time Configuration method (environment + * variables or JS file). Configuration defined in a JS file will override environment variables. + * + * ##### Build-time Configuration + * + * Build-time configuration methods add config variables into the app when it is built by webpack. + * This saves the app an API call and means it has all the information it needs to initialize right + * away. There are two methods of supplying build-time configuration: environment variables and a + * JavaScript file. + * + * ###### 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`. + * + * This configuration method is being deprecated in favor of JavaScript File Configuration. + * + * ###### 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. + * + * Using a function or async function allows the configuration to be resolved at runtime (because + * the function will be executed at runtime). This is not common, and the capability is included + * for the sake of flexibility. + * + * JavaScript File Configuration is well-suited to extensibility use cases or component overrides, + * in that the configuration file can depend on any installed JavaScript module. It is also the + * preferred way of doing build-time configuration if runtime configuration isn't used by your + * deployment of the platform. + * + * 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. + * + * Runtime configuration is particularly useful if you need to supply different configurations to + * a single deployment of a micro-frontend, for instance. It is also a perfectly valid alternative + * to build-time configuration, though it introduces an additional API call to edx-platform on MFE + * initialization. + * + * ##### 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 +175,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 +199,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 +275,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.async.function.config.test.js b/src/initialize.async.function.config.test.js new file mode 100644 index 000000000..1ee0250ed --- /dev/null +++ b/src/initialize.async.function.config.test.js @@ -0,0 +1,44 @@ +import PubSub from 'pubsub-js'; +import { initialize } from './initialize'; + +import { + logError, +} from './logging'; +import { + ensureAuthenticatedUser, + fetchAuthenticatedUser, + hydrateAuthenticatedUser, +} from './auth'; +import { getConfig } from './config'; + +jest.mock('./logging'); +jest.mock('./auth'); +jest.mock('./analytics'); +jest.mock('./i18n'); +jest.mock('./auth/LocalForageCache'); +jest.mock('env.config.js', () => async () => new Promise((resolve) => { + resolve({ + JS_FILE_VAR: 'JS_FILE_VAR_VALUE_ASYNC_FUNCTION', + }); +})); + +let config = null; + +describe('initialize with async function js file config', () => { + beforeEach(() => { + jest.resetModules(); + config = getConfig(); + fetchAuthenticatedUser.mockReset(); + ensureAuthenticatedUser.mockReset(); + hydrateAuthenticatedUser.mockReset(); + logError.mockReset(); + PubSub.clearAllSubscriptions(); + }); + + it('should initialize the app with async function javascript file configuration', async () => { + const messages = { i_am: 'a message' }; + await initialize({ messages }); + + expect(config.JS_FILE_VAR).toEqual('JS_FILE_VAR_VALUE_ASYNC_FUNCTION'); + }); +}); diff --git a/src/initialize.const.config.test.js b/src/initialize.const.config.test.js new file mode 100644 index 000000000..12091328f --- /dev/null +++ b/src/initialize.const.config.test.js @@ -0,0 +1,42 @@ +import PubSub from 'pubsub-js'; +import { initialize } from './initialize'; + +import { + logError, +} from './logging'; +import { + ensureAuthenticatedUser, + fetchAuthenticatedUser, + hydrateAuthenticatedUser, +} from './auth'; +import { getConfig } from './config'; + +jest.mock('./logging'); +jest.mock('./auth'); +jest.mock('./analytics'); +jest.mock('./i18n'); +jest.mock('./auth/LocalForageCache'); +jest.mock('env.config.js', () => ({ + JS_FILE_VAR: 'JS_FILE_VAR_VALUE_CONSTANT', +})); + +let config = null; + +describe('initialize with constant js file config', () => { + beforeEach(() => { + jest.resetModules(); + config = getConfig(); + fetchAuthenticatedUser.mockReset(); + ensureAuthenticatedUser.mockReset(); + hydrateAuthenticatedUser.mockReset(); + logError.mockReset(); + PubSub.clearAllSubscriptions(); + }); + + 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_CONSTANT'); + }); +}); diff --git a/src/initialize.function.config.test.js b/src/initialize.function.config.test.js new file mode 100644 index 000000000..06c13fc01 --- /dev/null +++ b/src/initialize.function.config.test.js @@ -0,0 +1,42 @@ +import PubSub from 'pubsub-js'; +import { initialize } from './initialize'; + +import { + logError, +} from './logging'; +import { + ensureAuthenticatedUser, + fetchAuthenticatedUser, + hydrateAuthenticatedUser, +} from './auth'; +import { getConfig } from './config'; + +jest.mock('./logging'); +jest.mock('./auth'); +jest.mock('./analytics'); +jest.mock('./i18n'); +jest.mock('./auth/LocalForageCache'); +jest.mock('env.config.js', () => () => ({ + JS_FILE_VAR: 'JS_FILE_VAR_VALUE_FUNCTION', +})); + +let config = null; + +describe('initialize with function js file config', () => { + beforeEach(() => { + jest.resetModules(); + config = getConfig(); + fetchAuthenticatedUser.mockReset(); + ensureAuthenticatedUser.mockReset(); + hydrateAuthenticatedUser.mockReset(); + logError.mockReset(); + PubSub.clearAllSubscriptions(); + }); + + 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_FUNCTION'); + }); +}); diff --git a/src/initialize.js b/src/initialize.js index 7af609398..d8d0b64e7 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'; @@ -131,13 +140,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(); @@ -264,6 +292,7 @@ export async function initialize({ // Configuration await handlers.config(); + await jsFileConfig(); await runtimeConfig(); publish(APP_CONFIG_INITIALIZED); @@ -271,15 +300,24 @@ export async function initialize({ config: getConfig(), }); + // 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, @@ -289,7 +327,7 @@ export async function initialize({ publish(APP_AUTH_INITIALIZED); // Analytics - configureAnalytics(analyticsService, { + configureAnalytics(analyticsServiceImpl, { config: getConfig(), loggingService: getLoggingService(), httpClient: getAuthenticatedHttpClient(),