src/node/plugin.ts: Implement new plugin API

This commit is contained in:
Anmol Sethi 2020-10-30 03:18:45 -04:00
parent 481df70622
commit e08a55d44a
No known key found for this signature in database
GPG Key ID: 8CEF1878FF10ADEB
2 changed files with 166 additions and 79 deletions

View File

@ -1,92 +1,177 @@
import { field, logger } from "@coder/logger"
import { Express } from "express"
import * as fs from "fs"
import * as path from "path" import * as path from "path"
import * as util from "util" import * as util from "./util"
import { Args } from "./cli" import * as pluginapi from "../../typings/plugin"
import { paths } from "./util" import * as fs from "fs"
import * as semver from "semver"
import { version } from "./constants"
const fsp = fs.promises
import { Logger, field } from "@coder/logger"
import * as express from "express"
/* eslint-disable @typescript-eslint/no-var-requires */ // These fields are populated from the plugin's package.json.
interface Plugin extends pluginapi.Plugin {
name: string
version: string
description: string
}
export type Activate = (app: Express, args: Args) => void interface Application extends pluginapi.Application {
plugin: Plugin
/**
* Plugins must implement this interface.
*/
export interface Plugin {
activate: Activate
} }
/** /**
* Intercept imports so we can inject code-server when the plugin tries to * PluginAPI implements the plugin API described in typings/plugin.d.ts
* import it. * Please see that file for details.
*/ */
const originalLoad = require("module")._load export class PluginAPI {
// eslint-disable-next-line @typescript-eslint/no-explicit-any private readonly plugins = new Array<Plugin>()
require("module")._load = function (request: string, parent: object, isMain: boolean): any { private readonly logger: Logger
return originalLoad.apply(this, [request.replace(/^code-server/, path.resolve(__dirname, "../..")), parent, isMain])
}
/** public constructor(
* Load a plugin and run its activation function. logger: Logger,
/**
* These correspond to $CS_PLUGIN_PATH and $CS_PLUGIN respectively.
*/ */
const loadPlugin = async (pluginPath: string, app: Express, args: Args): Promise<void> => { private readonly csPlugin = "",
try { private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`,
const plugin: Plugin = require(pluginPath) ){
plugin.activate(app, args) this.logger = logger.named("pluginapi")
}
const packageJson = require(path.join(pluginPath, "package.json")) /**
logger.debug( * applications grabs the full list of applications from
"Loaded plugin", * all loaded plugins.
field("name", packageJson.name || path.basename(pluginPath)), */
field("path", pluginPath), public async applications(): Promise<Application[]> {
field("version", packageJson.version || "n/a"), const apps = new Array<Application>()
for (let p of this.plugins) {
const pluginApps = await p.applications()
// TODO prevent duplicates
// Add plugin key to each app.
apps.push(
...pluginApps.map((app) => {
return { ...app, plugin: p }
}),
) )
} catch (error) {
logger.error(error.message)
} }
} return apps
}
/** /**
* Load all plugins in the specified directory. * mount mounts all plugin routers onto r.
*/ */
const _loadPlugins = async (pluginDir: string, app: Express, args: Args): Promise<void> => { public mount(r: express.Router): void {
try { for (let p of this.plugins) {
const files = await util.promisify(fs.readdir)(pluginDir, { r.use(`/${p.name}`, p.router())
withFileTypes: true,
})
await Promise.all(files.map((file) => loadPlugin(path.join(pluginDir, file.name), app, args)))
} catch (error) {
if (error.code !== "ENOENT") {
logger.warn(error.message)
} }
} }
}
/** /**
* Load all plugins from the `plugins` directory, directories specified by * loadPlugins loads all plugins based on this.csPluginPath
* `CS_PLUGIN_PATH` (colon-separated), and individual plugins specified by * and this.csPlugin.
* `CS_PLUGIN` (also colon-separated).
*/ */
export const loadPlugins = async (app: Express, args: Args): Promise<void> => { public async loadPlugins(): Promise<void> {
const pluginPath = process.env.CS_PLUGIN_PATH || `${path.join(paths.data, "plugins")}:/usr/share/code-server/plugins`
const plugin = process.env.CS_PLUGIN || ""
await Promise.all([
// Built-in plugins. // Built-in plugins.
_loadPlugins(path.resolve(__dirname, "../../plugins"), app, args), await this._loadPlugins(path.join(__dirname, "../../plugins"))
// User-added plugins.
...pluginPath for (let dir of this.csPluginPath.split(":")) {
.split(":") if (!dir) {
.filter((p) => !!p) continue
.map((dir) => _loadPlugins(path.resolve(dir), app, args)), }
// Individual plugins so you don't have to symlink or move them into a await this._loadPlugins(dir)
// directory specifically for plugins. This lets you load plugins that are }
// on the same level as other directories that are not plugins (if you tried
// to use CS_PLUGIN_PATH code-server would try to load those other for (let dir of this.csPlugin.split(":")) {
// directories as plugins). Intended for development. if (!dir) {
...plugin continue
.split(":") }
.filter((p) => !!p) await this.loadPlugin(dir)
.map((dir) => loadPlugin(path.resolve(dir), app, args)), }
]) }
private async _loadPlugins(dir: string): Promise<void> {
try {
const entries = await fsp.readdir(dir, { withFileTypes: true })
for (let ent of entries) {
if (!ent.isDirectory()) {
continue
}
await this.loadPlugin(path.join(dir, ent.name))
}
} catch (err) {
if (err.code !== "ENOENT") {
this.logger.warn(`failed to load plugins from ${q(dir)}: ${err.message}`)
}
}
}
private async loadPlugin(dir: string): Promise<void> {
try {
const str = await fsp.readFile(path.join(dir, "package.json"), {
encoding: "utf8",
})
const packageJSON: PackageJSON = JSON.parse(str)
const p = this._loadPlugin(dir, packageJSON)
// TODO prevent duplicates
this.plugins.push(p)
} catch (err) {
if (err.code !== "ENOENT") {
this.logger.warn(`failed to load plugin: ${err.message}`)
}
}
}
private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin {
const logger = this.logger.named(packageJSON.name)
logger.debug("loading plugin",
field("plugin_dir", dir),
field("package_json", packageJSON),
)
if (!semver.satisfies(version, packageJSON.engines["code-server"])) {
throw new Error(`plugin range ${q(packageJSON.engines["code-server"])} incompatible` +
` with code-server version ${version}`)
}
if (!packageJSON.name) {
throw new Error("plugin missing name")
}
if (!packageJSON.version) {
throw new Error("plugin missing version")
}
if (!packageJSON.description) {
throw new Error("plugin missing description")
}
const p = {
name: packageJSON.name,
version: packageJSON.version,
description: packageJSON.description,
...require(dir),
} as Plugin
p.init({
logger: logger,
})
logger.debug("loaded")
return p
}
}
interface PackageJSON {
name: string
version: string
description: string
engines: {
"code-server": string
}
}
function q(s: string): string {
if (s === undefined) {
s = "undefined"
}
return JSON.stringify(s)
} }

View File

@ -12,7 +12,7 @@ import { AuthType, DefaultedArgs } from "../cli"
import { rootPath } from "../constants" import { rootPath } from "../constants"
import { Heart } from "../heart" import { Heart } from "../heart"
import { replaceTemplates } from "../http" import { replaceTemplates } from "../http"
import { loadPlugins } from "../plugin" import { PluginAPI } from "../plugin"
import { getMediaMime, paths } from "../util" import { getMediaMime, paths } from "../util"
import { WebsocketRequest } from "../wsRouter" import { WebsocketRequest } from "../wsRouter"
import * as domainProxy from "./domainProxy" import * as domainProxy from "./domainProxy"
@ -115,7 +115,9 @@ export const register = async (
app.use("/static", _static.router) app.use("/static", _static.router)
app.use("/update", update.router) app.use("/update", update.router)
await loadPlugins(app, args) const papi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH)
await papi.loadPlugins()
papi.mount(app)
app.use(() => { app.use(() => {
throw new HttpError("Not Found", HttpCode.NotFound) throw new HttpError("Not Found", HttpCode.NotFound)