Wire up module loading to application startup (#21703)
* Early module loader bundler * Add a module installer script * Add dev-friendly docs * Add real module-api dependency * Speed up `yarn add` for mulitple modules * Fix version check for modules * Appease the linterpull/13/merge
parent
f03200f8e6
commit
f1e5b95554
@ -1,3 +1,5 @@ |
||||
src/vector/modernizr.js |
||||
# Legacy skinning file that some people might still have |
||||
src/component-index.js |
||||
# Auto-generated file |
||||
src/modules.ts |
||||
|
||||
@ -0,0 +1,25 @@ |
||||
# A sample build_config.yaml to supply to Element Web's build pipeline, |
||||
# enabling custom functionality at compile time. Copy this file to |
||||
# `build_config.yaml` in the same directory to use, as you would with |
||||
# `config.json`. |
||||
# |
||||
# Note: The vast majority of users DO NOT need this. If you are looking |
||||
# to build your own Element Web as seen on app.element.io or similar then |
||||
# this is not required. |
||||
# |
||||
# This config file does become required if you are looking to add runtime |
||||
# functionality to Element Web, such as customisation endpoints and modules. |
||||
# |
||||
# Over time we might expand this config to better support some use cases. |
||||
# Watch the release notes for features which might impact this config. |
||||
|
||||
# The modules to install. See ./docs/modules.md for more information on |
||||
# what modules are. |
||||
# |
||||
# The values of this are provided to `yarn add` for inclusion. |
||||
modules: |
||||
# An example of pulling a module from NPM |
||||
- "@vector-im/element-web-ilag-module@^0.0.1" |
||||
|
||||
# An example of pulling a module from github |
||||
- "github:vector-im/element-web-ilag-module#main" |
||||
@ -0,0 +1,48 @@ |
||||
# Module system |
||||
|
||||
The module system in Element Web is a way to add or modify functionality of Element Web itself, bundled at compile time |
||||
for the app. This means that modules are loaded as part of the `yarn build` process but have an effect on user experience |
||||
at runtime. |
||||
|
||||
## Installing modules |
||||
|
||||
If you already have a module you want to install, such as our [ILAG Module](https://github.com/vector-im/element-web-ilag-module), |
||||
then copy `build_config.sample.yaml` to `build_config.yaml` in the same directory. In your new `build_config.yaml` simply |
||||
add the reference to the module as described by the sample file, using the same syntax you would for `yarn add`: |
||||
|
||||
```yaml |
||||
modules: |
||||
# Our module happens to be published on NPM, so we use that syntax to reference it. |
||||
- "@vector-im/element-web-ilag-module@latest" |
||||
``` |
||||
|
||||
Then build the app as you normally would: `yarn build` or `yarn dist` (if compatible on your platform). If you are building |
||||
the Docker image then ensure your `build_config.yaml` ends up in the build directory. Usually this works fine if you use |
||||
the current directory as the build context (the `.` in `docker build -t my-element-web .`). |
||||
|
||||
## Writing modules |
||||
|
||||
While writing modules is meant to be easy, not everything is possible yet. For modules which want to do something we haven't |
||||
exposed in the module API, the module API will need to be updated. This means a PR to both the |
||||
[`matrix-react-sdk`](https://github.com/matrix-org/matrix-react-sdk) and [`matrix-react-sdk-module-api`](https://github.com/matrix-org/matrix-react-sdk-module-api). |
||||
|
||||
Once your change to the module API is accepted, the `@matrix-org/react-sdk-module-api` dependency gets updated at the |
||||
`matrix-react-sdk` and `element-web` layers (usually by us, the maintainers) to ensure your module can operate. |
||||
|
||||
If you're not adding anything to the module API, or your change was accepted per above, then start off with a clone of |
||||
our [ILAG module](https://github.com/vector-im/element-web-ilag-module) which will give you a general idea for what the |
||||
structure of a module is and how it works. |
||||
|
||||
The following requirements are key for any module: |
||||
1. The module must depend on `@matrix-org/react-sdk-module-api` (usually as a dev dependency). |
||||
2. The module's `main` entrypoint must have a `default` export for the `RuntimeModule` instance, supporting a constructor |
||||
which takes a single parameter: a `ModuleApi` instance. This instance is passed to `super()`. |
||||
3. The module must be deployed in a way where `yarn add` can access it, as that is how the build system will try to |
||||
install it. Note that while this is often NPM, it can also be a GitHub/GitLab repo or private NPM registry. |
||||
|
||||
... and that's pretty much it. As with any code, please be responsible and call things in line with the documentation. |
||||
Both `RuntimeModule` and `ModuleApi` have extensive documentation to describe what is proper usage and how to set things |
||||
up. |
||||
|
||||
If you have any questions then please visit [#element-dev:matrix.org](https://matrix.to/#/#element-dev:matrix.org) on |
||||
Matrix and we'll help as best we can. |
||||
@ -0,0 +1,33 @@ |
||||
/* |
||||
Copyright 2022 New Vector Ltd. |
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
you may not use this file except in compliance with the License. |
||||
You may obtain a copy of the License at |
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software |
||||
distributed under the License is distributed on an "AS IS" BASIS, |
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
See the License for the specific language governing permissions and |
||||
limitations under the License. |
||||
*/ |
||||
|
||||
import * as YAML from "yaml"; |
||||
import * as fs from "fs"; |
||||
|
||||
export type BuildConfig = { |
||||
// Dev note: make everything here optional for user safety. Invalid
|
||||
// configs are very possible.
|
||||
|
||||
// The module references to include in the build.
|
||||
modules?: string[]; |
||||
}; |
||||
|
||||
export function readBuildConfig(): BuildConfig { |
||||
if (fs.existsSync("./build_config.yaml")) { |
||||
return YAML.parse(fs.readFileSync("./build_config.yaml", "utf-8")); |
||||
} |
||||
return {}; // no config
|
||||
} |
||||
@ -0,0 +1,191 @@ |
||||
/* |
||||
Copyright 2022 New Vector Ltd. |
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
you may not use this file except in compliance with the License. |
||||
You may obtain a copy of the License at |
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software |
||||
distributed under the License is distributed on an "AS IS" BASIS, |
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
See the License for the specific language governing permissions and |
||||
limitations under the License. |
||||
*/ |
||||
|
||||
import * as fs from "fs"; |
||||
import * as childProcess from "child_process"; |
||||
import * as semver from "semver"; |
||||
|
||||
import { BuildConfig } from "./BuildConfig"; |
||||
|
||||
// This expects to be run from ./scripts/install.ts
|
||||
|
||||
const moduleApiDepName = "@matrix-org/react-sdk-module-api"; |
||||
|
||||
const MODULES_TS_HEADER = ` |
||||
/* |
||||
* THIS FILE IS AUTO-GENERATED |
||||
* You can edit it you like, but your changes will be overwritten, |
||||
* so you'd just be trying to swim upstream like a salmon. |
||||
* You are not a salmon. |
||||
*/ |
||||
|
||||
import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule"; |
||||
`;
|
||||
const MODULES_TS_DEFINITIONS = ` |
||||
export const INSTALLED_MODULES: RuntimeModule[] = []; |
||||
`;
|
||||
|
||||
export function installer(config: BuildConfig): void { |
||||
if (!config.modules?.length) { |
||||
// nothing to do
|
||||
writeModulesTs(MODULES_TS_HEADER + MODULES_TS_DEFINITIONS); |
||||
return; |
||||
} |
||||
|
||||
let exitCode = 0; |
||||
|
||||
// We cheat a bit and store the current package.json and lockfile so we can safely
|
||||
// run `yarn add` without creating extra committed files for people. We restore
|
||||
// these files by simply overwriting them when we're done.
|
||||
const packageDeps = readCurrentPackageDetails(); |
||||
|
||||
// Record which optional dependencies there are currently, if any, so we can exclude
|
||||
// them from our "must be a module" assumption later on.
|
||||
const currentOptDeps = getOptionalDepNames(packageDeps.packageJson); |
||||
|
||||
try { |
||||
// Install the modules with yarn
|
||||
const yarnAddRef = config.modules.join(" "); |
||||
callYarnAdd(yarnAddRef); // install them all at once
|
||||
|
||||
// Grab the optional dependencies again and exclude what was there already. Everything
|
||||
// else must be a module, we assume.
|
||||
const pkgJsonStr = fs.readFileSync("./package.json", "utf-8"); |
||||
const optionalDepNames = getOptionalDepNames(pkgJsonStr); |
||||
const installedModules = optionalDepNames.filter(d => !currentOptDeps.includes(d)); |
||||
|
||||
// Ensure all the modules are compatible. We check them all and report at the end to
|
||||
// try and save the user some time debugging this sort of failure.
|
||||
const ourApiVersion = getTopLevelDependencyVersion(moduleApiDepName); |
||||
const incompatibleNames: string[] = []; |
||||
for (const moduleName of installedModules) { |
||||
const modApiVersion = getModuleApiVersionFor(moduleName); |
||||
if (!isModuleVersionCompatible(ourApiVersion, modApiVersion)) { |
||||
incompatibleNames.push(moduleName); |
||||
} |
||||
} |
||||
if (incompatibleNames.length > 0) { |
||||
console.error( |
||||
"The following modules are not compatible with this version of element-web. Please update the module " + |
||||
"references and try again.", |
||||
JSON.stringify(incompatibleNames, null, 4), // stringify to get prettier/complete output
|
||||
); |
||||
exitCode = 1; |
||||
return; // hit the finally{} block before exiting
|
||||
} |
||||
|
||||
// If we reach here, everything seems fine. Write modules.ts and log some output
|
||||
// Note: we compile modules.ts in two parts for developer friendliness if they
|
||||
// happen to look at it.
|
||||
console.log("The following modules have been installed: ", installedModules); |
||||
let modulesTsHeader = MODULES_TS_HEADER; |
||||
let modulesTsDefs = MODULES_TS_DEFINITIONS; |
||||
let index = 0; |
||||
for (const moduleName of installedModules) { |
||||
const importName = `Module${++index}`; |
||||
modulesTsHeader += `import ${importName} from "${moduleName}";\n`; |
||||
modulesTsDefs += `INSTALLED_MODULES.push(${importName});\n`; |
||||
} |
||||
writeModulesTs(modulesTsHeader + modulesTsDefs); |
||||
console.log("Done installing modules"); |
||||
} finally { |
||||
// Always restore package details (or at least try to)
|
||||
writePackageDetails(packageDeps); |
||||
|
||||
if (exitCode > 0) { |
||||
process.exit(exitCode); |
||||
} |
||||
} |
||||
} |
||||
|
||||
type RawDependencies = { |
||||
lockfile: string; |
||||
packageJson: string; |
||||
}; |
||||
|
||||
function readCurrentPackageDetails(): RawDependencies { |
||||
return { |
||||
lockfile: fs.readFileSync("./yarn.lock", "utf-8"), |
||||
packageJson: fs.readFileSync("./package.json", "utf-8"), |
||||
}; |
||||
} |
||||
|
||||
function writePackageDetails(deps: RawDependencies) { |
||||
fs.writeFileSync("./yarn.lock", deps.lockfile, "utf-8"); |
||||
fs.writeFileSync("./package.json", deps.packageJson, "utf-8"); |
||||
} |
||||
|
||||
function callYarnAdd(dep: string) { |
||||
// Add the module to the optional dependencies section just in case something
|
||||
// goes wrong in restoring the original package details.
|
||||
childProcess.execSync(`yarn add -O ${dep}`, { |
||||
env: process.env, |
||||
stdio: ['inherit', 'inherit', 'inherit'], |
||||
}); |
||||
} |
||||
|
||||
function getOptionalDepNames(pkgJsonStr: string): string[] { |
||||
return Object.keys(JSON.parse(pkgJsonStr)?.['optionalDependencies'] ?? {}); |
||||
} |
||||
|
||||
function findDepVersionInPackageJson(dep: string, pkgJsonStr: string): string { |
||||
const pkgJson = JSON.parse(pkgJsonStr); |
||||
const packages = { |
||||
...(pkgJson['optionalDependencies'] ?? {}), |
||||
...(pkgJson['devDependencies'] ?? {}), |
||||
...(pkgJson['dependencies'] ?? {}), |
||||
}; |
||||
return packages[dep]; |
||||
} |
||||
|
||||
function getTopLevelDependencyVersion(dep: string): string { |
||||
const dependencyTree = JSON.parse(childProcess.execSync(`npm list ${dep} --depth=0 --json`, { |
||||
env: process.env, |
||||
stdio: ['inherit', 'pipe', 'pipe'], |
||||
}).toString('utf-8')); |
||||
|
||||
/* |
||||
What a dependency tree looks like: |
||||
{ |
||||
"version": "1.10.13", |
||||
"name": "element-web", |
||||
"dependencies": { |
||||
"@matrix-org/react-sdk-module-api": { |
||||
"version": "0.0.1", |
||||
"resolved": "file:../../../matrix-react-sdk-module-api" |
||||
} |
||||
} |
||||
} |
||||
*/ |
||||
|
||||
return dependencyTree["dependencies"][dep]["version"]; |
||||
} |
||||
|
||||
function getModuleApiVersionFor(moduleName: string): string { |
||||
// We'll just pretend that this isn't highly problematic...
|
||||
// Yarn is fairly stable in putting modules in a flat hierarchy, at least.
|
||||
const pkgJsonStr = fs.readFileSync(`./node_modules/${moduleName}/package.json`, "utf-8"); |
||||
return findDepVersionInPackageJson(moduleApiDepName, pkgJsonStr); |
||||
} |
||||
|
||||
function isModuleVersionCompatible(ourApiVersion: string, moduleApiVersion: string): boolean { |
||||
if (!moduleApiVersion) return false; |
||||
return semver.satisfies(ourApiVersion, moduleApiVersion); |
||||
} |
||||
|
||||
function writeModulesTs(content: string) { |
||||
fs.writeFileSync("./src/modules.ts", content, "utf-8"); |
||||
} |
||||
@ -0,0 +1,21 @@ |
||||
/* |
||||
Copyright 2022 New Vector Ltd. |
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
you may not use this file except in compliance with the License. |
||||
You may obtain a copy of the License at |
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software |
||||
distributed under the License is distributed on an "AS IS" BASIS, |
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
See the License for the specific language governing permissions and |
||||
limitations under the License. |
||||
*/ |
||||
|
||||
import { readBuildConfig } from "../BuildConfig"; |
||||
import { installer } from "../installer"; |
||||
|
||||
const buildConf = readBuildConfig(); |
||||
installer(buildConf); |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"extends": "./tsconfig.json", |
||||
"compilerOptions": { |
||||
"jsx": "preserve", |
||||
"declaration": false, |
||||
"outDir": "./lib/module_system", |
||||
"lib": [ |
||||
"es2019" |
||||
] |
||||
}, |
||||
"include": [ |
||||
"./module_system/**/*.ts" |
||||
] |
||||
} |
||||
Loading…
Reference in new issue