Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: JavaScript file configuration of apps #474

Merged
merged 7 commits into from
May 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions docs/decisions/0007-javascript-file-configuration.rst
Original file line number Diff line number Diff line change
@@ -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 <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.

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
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. 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;
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",
Copy link
Contributor Author

@davidjoy davidjoy Apr 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the version of frontend-build that enables env.config as a magic import. So we didn't have a peer dependency here before, but we technically do now. It's unlikely to bite anyone since 8.1.0 was quite a while ago.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[curious/thinking aloud] Is there a way we could treat @edx/frontend-build as an optional dependency instead (docs)? In theory, someone may have created or could create an MFE using @edx/frontend-platform without @edx/frontend-build; it feels like introducing the peer dependency is potentially a breaking change as a result, even though I agree it's probably fair to assume this won't bite anyone.

I believe the challenge with treating @edx/frontend-build as an optionalDependency would be conditionally importing env.config.js and only using it, if the import was successful.

FWIW, I don't feel too strongly about this suggestion; just thinking of potential alternatives that don't require a coupling with @edx/frontend-build for the Webpack implementation should consumers of @edx/frontend-platform ever want to use a different implementation of Webpack than @edx/frontend-build 😃

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the record (Adam and I talked about this a bit offline) - the only way I could get env.config to work as an import was to use specialized webpack config (in frontend-build) to fallback to an empty import if the file couldn't be found. Since the entire mechanism is predicated on that behavior, and it's implemented in frontend-build, it feels a bit unavoidable to add the peer dependency. There's just not a way I've ever found to do this without that added magic (which was merged into frontend-build some time ago in openedx/frontend-build#200)

Copy link
Member

@adamstankiewicz adamstankiewicz May 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, we may have ended up needing to do something similar anyways given the direction this PR is headed to be able to fallback to a local Paragon version in the externally hosted Paragon CSS urls or if a externally hosted Paragon CSS urls fails to load, we need to expose the paths to the locally installed Paragon theme via frontend-build such that frontend-platform has access to them (otherwise, each MFE would have to pass args to initialize which feels like could be avoided).

"prop-types": "^15.7.2",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0",
Expand Down
155 changes: 138 additions & 17 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
davidjoy marked this conversation as resolved.
Show resolved Hide resolved
*
* 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
Expand Down Expand Up @@ -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}
*/
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
*
* ```
* {
Expand Down
Loading