riot-web/module_system/installer.ts

207 lines
7.7 KiB
TypeScript

/*
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): void {
fs.writeFileSync("./yarn.lock", deps.lockfile, "utf-8");
fs.writeFileSync("./package.json", deps.packageJson, "utf-8");
}
function callYarnAdd(dep: string): void {
// 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);
}
// A list of Module API versions that are supported in addition to the currently installed one
// defined in the package.json. This is necessary because semantic versioning is applied to both
// the Module-side surface of the API and the Client-side surface of the API. So breaking changes
// in the Client-side surface lead to a major bump even though the Module-side surface stays
// compatible. We aim to not break the Module-side surface so we maintain a list of compatible
// older versions.
const backwardsCompatibleMajorVersions = ["1.0.0"];
function isModuleVersionCompatible(ourApiVersion: string, moduleApiVersion: string): boolean {
if (!moduleApiVersion) return false;
return (
semver.satisfies(ourApiVersion, moduleApiVersion) ||
backwardsCompatibleMajorVersions.some((version) => semver.satisfies(version, moduleApiVersion))
);
}
function writeModulesTs(content: string): void {
fs.writeFileSync("./src/modules.ts", content, "utf-8");
}