gristlabs_grist-core/app/server/lib/PluginManager.ts
Paul Fitzpatrick cc9a9ae8c5 (core) support for bundling custom widgets with the Grist app
Summary:
This adds support for bundling custom widgets with the Grist app, as follows:

 * Adds a new `widgets` component to plugins mechanism.
 * When a set of widgets is provided in a plugin, the html/js/css assets for those widgets are served on the existing untrusted user content port.
 * Any bundled `grist-plugin-api.js` will be served with the Grist app's own version of that file. It is important that bundled widgets not refer to https://docs.getgrist.com for the plugin js, since they must be capable of working offline.
 * The logic for configuring that port is updated a bit.
 * I removed the CustomAttachedView class in favor of applying settings of bundled custom widgets more directly, without modification on view.

Any Grist installation via docker will need an extra step now, since there is an extra port that needs exposing for full functionality. I did add a `GRIST_TRUST_PLUGINS` option for anyone who really doesn't want to do this, and would prefer to trust the plugins and have them served on the same port.

Actually making use of bundling will be another step. It'll be important to mesh it with our SaaS's use of APP_STATIC_URL for serving most static assets.

Design sketch: https://grist.quip.com/bJlWACWzr2R9/Bundled-custom-widgets

Test Plan: added a test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4069
2023-10-27 17:00:10 -04:00

168 lines
4.7 KiB
TypeScript

import {DirectoryScanEntry, LocalPlugin} from 'app/common/plugin';
import log from 'app/server/lib/log';
import {readManifest} from 'app/server/lib/manifest';
import {getAppPathTo} from 'app/server/lib/places';
import * as fse from 'fs-extra';
import * as path from 'path';
/**
* Various plugins' related directories.
*/
export interface PluginDirectories {
/**
* Directory where built in plugins are located.
*/
readonly builtIn?: string;
/**
* Directory where user installed plugins are localted.
*/
readonly installed?: string;
}
/**
*
* The plugin manager class is responsible for providing both built in and installed plugins and
* spawning server side plugins's.
*
* Usage:
*
* const pluginManager = new PluginManager(appRoot, userRoot);
* await pluginManager.initialize();
*
*/
export class PluginManager {
public pluginsLoaded: Promise<void>;
// ========== Instance members and methods ==========
private _dirs: PluginDirectories;
private _validPlugins: LocalPlugin[] = [];
private _entries: DirectoryScanEntry[] = [];
/**
* @param {string} userRoot: path to user's grist directory; `null` is allowed, to only uses built in plugins.
*
*/
public constructor(public appRoot?: string, userRoot?: string) {
this._dirs = {
installed: userRoot ? path.join(userRoot, 'plugins') : undefined,
builtIn: appRoot ? getAppPathTo(appRoot, 'plugins') : undefined
};
}
public dirs(): PluginDirectories { return this._dirs; }
/**
* Create tmp dir and load plugins.
*/
public async initialize(): Promise<void> {
try {
await (this.pluginsLoaded = this.loadPlugins());
} catch (err) {
log.error("PluginManager's initialization failed: ", err);
throw err;
}
}
/**
* Re-load plugins (literally re-run `loadPlugins`).
*/
// TODO: it's not clear right now what we do on reload. Do we deactivate plugins that were removed
// from the fs? Do we update plugins that have changed on the fs ?
public async reloadPlugins(): Promise<void> {
return await this.loadPlugins();
}
/**
* Discover both builtIn and user installed plugins. Logs any failures that happens when scanning
* a directory (ie: manifest missing or manifest validation errors etc...)
*/
public async loadPlugins(): Promise<void> {
this._entries = [];
// Load user installed plugins
if (this._dirs.installed) {
this._entries.push(...await scanDirectory(this._dirs.installed, "installed"));
}
// Load builtIn plugins
if (this._dirs.builtIn) {
this._entries.push(...await scanDirectory(this._dirs.builtIn, "builtIn"));
}
if (!process.env.GRIST_EXPERIMENTAL_PLUGINS ||
process.env.GRIST_EXPERIMENTAL_PLUGINS === '0') {
// Remove experimental plugins
this._entries = this._entries.filter(entry => {
if (entry.manifest && entry.manifest.experimental) {
log.warn("Ignoring experimental plugin %s", entry.id);
return false;
}
return true;
});
}
this._validPlugins = this._entries.filter(entry => !entry.errors).map(entry => entry as LocalPlugin);
this._logScanningReport();
}
public getPlugins(): LocalPlugin[] {
return this._validPlugins;
}
private _logScanningReport() {
const invalidPlugins = this._entries.filter( entry => entry.errors);
if (invalidPlugins.length) {
for (const plugin of invalidPlugins) {
log.warn(`Error loading plugins: Failed to load extension from ${plugin.path}\n` +
(plugin.errors!).map(m => " - " + m).join("\n ")
);
}
}
log.info(`Found ${this._validPlugins.length} valid plugins on the system`);
for (const p of this._validPlugins) {
log.debug("PLUGIN %s -- %s", p.id, p.path);
}
}
}
async function scanDirectory(dir: string, kind: "installed"|"builtIn"): Promise<DirectoryScanEntry[]> {
const plugins: DirectoryScanEntry[] = [];
let listDir;
try {
listDir = await fse.readdir(dir);
} catch (e) {
// Non existing dir is treated as an empty dir.
// It is hard for user to avoid Grist checking a dir,
// so phrase the message as information rather than error.
log.info(`No plugins found in directory: ${dir}`);
return [];
}
for (const id of listDir) {
const folderPath = path.join(dir, id),
plugin: DirectoryScanEntry = {
path: folderPath,
id: `${kind}/${id}`
};
try {
plugin.manifest = await readManifest(folderPath);
} catch (e) {
plugin.errors = [];
if (e.message) {
plugin.errors.push(e.message);
}
if (e.notices) {
plugin.errors.push(...e.notices);
}
}
plugins.push(plugin);
}
return plugins;
}