-
Notifications
You must be signed in to change notification settings - Fork 6k
Plugin API to add more applications to code-server #2252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
aa2cfa2
typings/plugin.d.ts: Create
nhooyr 481df70
ci/dev/test.sh: Pass through args
nhooyr e08a55d
src/node/plugin.ts: Implement new plugin API
nhooyr bea185b
plugin: Add basic loading test
nhooyr 82e8a00
Fix CI
nhooyr 30d2962
src/node/plugin.ts: Warn on duplicate plugin and only load first
nhooyr ef97100
plugin.test.ts: Make it clear iconPath is a path
nhooyr f4d7f00
plugin.ts: Fixes for @wbobeirne
nhooyr 75e52a3
plugin.ts: Fixes for @code-asher
nhooyr 8d3a772
plugin.d.ts: Document plugin priority correctly
nhooyr 6638daf
plugin.d.ts: Add explicit path field and adjust types to reflect
nhooyr fed545e
plugin.d.ts -> pluginapi.d.ts
nhooyr afff86a
plugin.ts: Adjust to implement pluginapi.d.ts correctly
nhooyr e03bbe3
routes/apps.ts: Implement /api/applications endpoint
nhooyr 139a28e
plugin.ts: Describe private counterpart functions
nhooyr 6870948
plugin.ts: Make application endpoint paths absolute
nhooyr 2a13d00
plugin.ts: Add homepageURL to plugin and application
nhooyr af73b96
routes/apps.ts: Add example output
nhooyr 706bc23
plugin: Fixes for CI
nhooyr 8a8159c
plugin: More review fixes
nhooyr 14f408a
plugin: Plugin modules now export a single top level identifier
nhooyr 9453f89
plugin.ts: Fix usage of routerPath in mount
nhooyr 197a09f
plugin: Test endpoints via supertest
nhooyr 9d39c53
plugin: Give test-plugin some html to test overlay
nhooyr 277211c
plugin: Make init and applications callbacks optional
nhooyr fe399ff
Fix formatting
nhooyr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,92 +1,249 @@ | ||
import { field, logger } from "@coder/logger" | ||
import { Express } from "express" | ||
import { Logger, field } from "@coder/logger" | ||
import * as express from "express" | ||
import * as fs from "fs" | ||
import * as path from "path" | ||
import * as util from "util" | ||
import { Args } from "./cli" | ||
import { paths } from "./util" | ||
import * as semver from "semver" | ||
import * as pluginapi from "../../typings/pluginapi" | ||
import { version } from "./constants" | ||
import * as util from "./util" | ||
const fsp = fs.promises | ||
|
||
/* eslint-disable @typescript-eslint/no-var-requires */ | ||
interface Plugin extends pluginapi.Plugin { | ||
/** | ||
* These fields are populated from the plugin's package.json | ||
* and now guaranteed to exist. | ||
*/ | ||
name: string | ||
version: string | ||
|
||
export type Activate = (app: Express, args: Args) => void | ||
|
||
/** | ||
* Plugins must implement this interface. | ||
*/ | ||
export interface Plugin { | ||
activate: Activate | ||
/** | ||
* path to the node module on the disk. | ||
*/ | ||
modulePath: string | ||
} | ||
|
||
/** | ||
* Intercept imports so we can inject code-server when the plugin tries to | ||
* import it. | ||
*/ | ||
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 { | ||
return originalLoad.apply(this, [request.replace(/^code-server/, path.resolve(__dirname, "../..")), parent, isMain]) | ||
interface Application extends pluginapi.Application { | ||
/* | ||
* Clone of the above without functions. | ||
*/ | ||
plugin: Omit<Plugin, "init" | "router" | "applications"> | ||
} | ||
|
||
/** | ||
* Load a plugin and run its activation function. | ||
* PluginAPI implements the plugin API described in typings/pluginapi.d.ts | ||
* Please see that file for details. | ||
*/ | ||
const loadPlugin = async (pluginPath: string, app: Express, args: Args): Promise<void> => { | ||
try { | ||
const plugin: Plugin = require(pluginPath) | ||
plugin.activate(app, args) | ||
|
||
const packageJson = require(path.join(pluginPath, "package.json")) | ||
logger.debug( | ||
"Loaded plugin", | ||
field("name", packageJson.name || path.basename(pluginPath)), | ||
field("path", pluginPath), | ||
field("version", packageJson.version || "n/a"), | ||
) | ||
} catch (error) { | ||
logger.error(error.message) | ||
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`, | ||
) { | ||
this.logger = logger.named("pluginapi") | ||
} | ||
} | ||
|
||
/** | ||
* Load all plugins in the specified directory. | ||
*/ | ||
const _loadPlugins = async (pluginDir: string, app: Express, args: Args): Promise<void> => { | ||
try { | ||
const files = await util.promisify(fs.readdir)(pluginDir, { | ||
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) | ||
/** | ||
* applications grabs the full list of applications from | ||
* all loaded plugins. | ||
*/ | ||
public async applications(): Promise<Application[]> { | ||
const apps = new Array<Application>() | ||
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) => { | ||
app = { ...app, path: path.join(p.routerPath, app.path || "") } | ||
app = { ...app, iconPath: path.join(app.path || "", app.iconPath) } | ||
return { | ||
...app, | ||
plugin: { | ||
name: p.name, | ||
version: p.version, | ||
modulePath: p.modulePath, | ||
|
||
displayName: p.displayName, | ||
description: p.description, | ||
routerPath: p.routerPath, | ||
homepageURL: p.homepageURL, | ||
}, | ||
} | ||
}), | ||
) | ||
} | ||
return apps | ||
} | ||
} | ||
|
||
/** | ||
* Load all plugins from the `plugins` directory, directories specified by | ||
* `CS_PLUGIN_PATH` (colon-separated), and individual plugins specified by | ||
* `CS_PLUGIN` (also colon-separated). | ||
*/ | ||
export const loadPlugins = async (app: Express, args: Args): 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([ | ||
/** | ||
* mount mounts all plugin routers onto r. | ||
*/ | ||
public mount(r: express.Router): void { | ||
for (const [, p] of this.plugins) { | ||
if (!p.router) { | ||
continue | ||
} | ||
r.use(`${p.routerPath}`, p.router()) | ||
} | ||
} | ||
|
||
/** | ||
* 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. | ||
_loadPlugins(path.resolve(__dirname, "../../plugins"), app, args), | ||
// User-added plugins. | ||
...pluginPath | ||
.split(":") | ||
.filter((p) => !!p) | ||
.map((dir) => _loadPlugins(path.resolve(dir), app, args)), | ||
// Individual plugins so you don't have to symlink or move them into a | ||
// 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 | ||
// directories as plugins). Intended for development. | ||
...plugin | ||
.split(":") | ||
.filter((p) => !!p) | ||
.map((dir) => loadPlugin(path.resolve(dir), app, args)), | ||
]) | ||
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 }) | ||
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) | ||
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 { | ||
nhooyr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
dir = path.resolve(dir) | ||
|
||
const logger = this.logger.named(packageJSON.name) | ||
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": { | ||
"code-server": "^3.6.0" | ||
} | ||
`) | ||
} | ||
if (!semver.satisfies(version, packageJSON.engines["code-server"])) { | ||
nhooyr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
} | ||
} | ||
|
||
interface PackageJSON { | ||
name: string | ||
version: string | ||
engines: { | ||
"code-server": string | ||
} | ||
} | ||
|
||
function q(s: string | undefined): string { | ||
if (s === undefined) { | ||
s = "undefined" | ||
} | ||
return JSON.stringify(s) | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import * as express from "express" | ||
import { PluginAPI } from "../plugin" | ||
|
||
/** | ||
* Implements the /api/applications endpoint | ||
* | ||
* See typings/pluginapi.d.ts for details. | ||
*/ | ||
export function router(papi: PluginAPI): express.Router { | ||
const router = express.Router() | ||
|
||
router.get("/", async (req, res) => { | ||
res.json(await papi.applications()) | ||
}) | ||
|
||
return router | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.