code-server/src/node/plugin.ts

266 lines
7.0 KiB
TypeScript
Raw Normal View History

2020-10-30 15:26:30 +08:00
import { Logger, field } from "@coder/logger"
import * as express from "express"
import * as fs from "fs"
2020-10-30 15:26:30 +08:00
import * as path from "path"
import * as semver from "semver"
import * as pluginapi from "../../typings/pluginapi"
import { version } from "./constants"
2020-10-30 15:26:30 +08:00
import * as util from "./util"
const fsp = fs.promises
2020-07-29 04:06:15 +08:00
2021-01-14 06:25:39 +08:00
/**
* Inject code-server when `require`d. This is required because the API provides
* more than just types so these need to be provided at run-time.
*/
const originalLoad = require("module")._load
// eslint-disable-next-line @typescript-eslint/no-explicit-any
require("module")._load = function (request: string, parent: object, isMain: boolean): any {
if (request === "code-server") {
return {
2021-01-14 06:26:11 +08:00
express,
2021-01-14 06:25:39 +08:00
field,
}
}
return originalLoad.apply(this, [request, parent, isMain])
}
interface Plugin extends pluginapi.Plugin {
/**
* These fields are populated from the plugin's package.json
* and now guaranteed to exist.
*/
name: string
version: string
/**
* path to the node module on the disk.
*/
modulePath: string
2020-07-29 04:06:15 +08:00
}
interface Application extends pluginapi.Application {
2020-11-04 05:24:06 +08:00
/*
* Clone of the above without functions.
*/
plugin: Omit<Plugin, "init" | "router" | "applications">
2020-07-29 04:06:15 +08:00
}
/**
* PluginAPI implements the plugin API described in typings/pluginapi.d.ts
* Please see that file for details.
*/
export class PluginAPI {
private readonly plugins = new Map<string, Plugin>()
private readonly logger: Logger
public constructor(
logger: Logger,
/**
* These correspond to $CS_PLUGIN_PATH and $CS_PLUGIN respectively.
*/
private readonly csPlugin = "",
private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`,
2020-10-30 15:26:30 +08:00
) {
this.logger = logger.named("pluginapi")
}
/**
* applications grabs the full list of applications from
* all loaded plugins.
*/
public async applications(): Promise<Application[]> {
const apps = new Array<Application>()
2020-11-04 10:53:16 +08:00
for (const [, p] of this.plugins) {
if (!p.applications) {
continue
}
const pluginApps = await p.applications()
// Add plugin key to each app.
apps.push(
...pluginApps.map((app) => {
2020-11-04 10:53:16 +08:00
app = { ...app, path: path.join(p.routerPath, app.path || "") }
app = { ...app, iconPath: path.join(app.path || "", app.iconPath) }
2020-11-04 05:24:06 +08:00
return {
...app,
plugin: {
name: p.name,
version: p.version,
modulePath: p.modulePath,
displayName: p.displayName,
description: p.description,
routerPath: p.routerPath,
homepageURL: p.homepageURL,
2020-11-04 05:24:06 +08:00
},
}
}),
)
}
return apps
}
/**
* mount mounts all plugin routers onto r.
*/
public mount(r: express.Router): void {
2020-11-04 10:53:16 +08:00
for (const [, p] of this.plugins) {
if (!p.router) {
continue
}
r.use(`${p.routerPath}`, p.router())
}
}
/**
2020-11-04 05:24:06 +08:00
* loadPlugins loads all plugins based on this.csPlugin,
* this.csPluginPath and the built in plugins.
*/
public async loadPlugins(): Promise<void> {
for (const dir of this.csPlugin.split(":")) {
if (!dir) {
continue
}
await this.loadPlugin(dir)
}
for (const dir of this.csPluginPath.split(":")) {
if (!dir) {
continue
}
await this._loadPlugins(dir)
}
// Built-in plugins.
await this._loadPlugins(path.join(__dirname, "../../plugins"))
}
/**
* _loadPlugins is the counterpart to loadPlugins.
*
* It differs in that it loads all plugins in a single
* directory whereas loadPlugins uses all available directories
* as documented.
*/
private async _loadPlugins(dir: string): Promise<void> {
try {
const entries = await fsp.readdir(dir, { withFileTypes: true })
2020-10-30 15:26:30 +08:00
for (const 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)
2020-11-04 10:53:16 +08:00
for (const [, p] of this.plugins) {
if (p.name === packageJSON.name) {
this.logger.warn(
`ignoring duplicate plugin ${q(p.name)} at ${q(dir)}, using previously loaded ${q(p.modulePath)}`,
)
return
}
}
const p = this._loadPlugin(dir, packageJSON)
this.plugins.set(p.name, p)
} catch (err) {
if (err.code !== "ENOENT") {
this.logger.warn(`failed to load plugin: ${err.stack}`)
}
}
}
/**
* _loadPlugin is the counterpart to loadPlugin and actually
* loads the plugin now that we know there is no duplicate
* and that the package.json has been read.
*/
private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin {
dir = path.resolve(dir)
const logger = this.logger.named(packageJSON.name)
2020-10-30 15:26:30 +08:00
logger.debug("loading plugin", field("plugin_dir", dir), field("package_json", packageJSON))
if (!packageJSON.name) {
throw new Error("plugin package.json missing name")
}
if (!packageJSON.version) {
throw new Error("plugin package.json missing version")
}
if (!packageJSON.engines || !packageJSON.engines["code-server"]) {
throw new Error(`plugin package.json missing code-server range like:
"engines": {
2020-11-16 23:50:05 +08:00
"code-server": "^3.7.0"
}
`)
}
if (!semver.satisfies(version, packageJSON.engines["code-server"])) {
2020-10-30 15:26:30 +08:00
throw new Error(
`plugin range ${q(packageJSON.engines["code-server"])} incompatible` + ` with code-server version ${version}`,
)
}
const pluginModule = require(dir)
if (!pluginModule.plugin) {
throw new Error("plugin module does not export a plugin")
}
const p = {
name: packageJSON.name,
version: packageJSON.version,
modulePath: dir,
...pluginModule.plugin,
} as Plugin
if (!p.displayName) {
throw new Error("plugin missing displayName")
}
if (!p.description) {
throw new Error("plugin missing description")
}
if (!p.routerPath) {
throw new Error("plugin missing router path")
}
if (!p.routerPath.startsWith("/") || p.routerPath.length < 2) {
throw new Error(`plugin router path ${q(p.routerPath)}: invalid`)
}
if (!p.homepageURL) {
throw new Error("plugin missing homepage")
}
p.init({
logger: logger,
})
logger.debug("loaded")
return p
2020-07-29 04:06:15 +08:00
}
}
interface PackageJSON {
name: string
version: string
engines: {
"code-server": string
2020-07-29 04:06:15 +08:00
}
}
2020-07-30 04:02:14 +08:00
2020-11-04 05:21:18 +08:00
function q(s: string | undefined): string {
if (s === undefined) {
s = "undefined"
}
return JSON.stringify(s)
2020-07-29 04:06:15 +08:00
}