Skip to content

Commit 8ae555e

Browse files
committed
src/node/plugin.ts: Implement new plugin API
1 parent 1a5c6e5 commit 8ae555e

File tree

2 files changed

+165
-78
lines changed

2 files changed

+165
-78
lines changed

src/node/plugin.ts

Lines changed: 161 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,177 @@
1-
import { field, logger } from "@coder/logger"
2-
import { Express } from "express"
3-
import * as fs from "fs"
41
import * as path from "path"
5-
import * as util from "util"
6-
import { Args } from "./cli"
7-
import { paths } from "./util"
8-
9-
/* eslint-disable @typescript-eslint/no-var-requires */
10-
11-
export type Activate = (app: Express, args: Args) => void
2+
import * as util from "./util"
3+
import * as pluginapi from "../../typings/plugin"
4+
import * as fs from "fs"
5+
import * as semver from "semver"
6+
import { version } from "./constants"
7+
const fsp = fs.promises
8+
import { Logger, field } from "@coder/logger"
9+
import * as express from "express"
1210

13-
/**
14-
* Plugins must implement this interface.
15-
*/
16-
export interface Plugin {
17-
activate: Activate
11+
// These fields are populated from the plugin's package.json.
12+
interface Plugin extends pluginapi.Plugin {
13+
name: string
14+
version: string
15+
description: string
1816
}
1917

20-
/**
21-
* Intercept imports so we can inject code-server when the plugin tries to
22-
* import it.
23-
*/
24-
const originalLoad = require("module")._load
25-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
26-
require("module")._load = function (request: string, parent: object, isMain: boolean): any {
27-
return originalLoad.apply(this, [request.replace(/^code-server/, path.resolve(__dirname, "../..")), parent, isMain])
18+
interface Application extends pluginapi.Application {
19+
plugin: Plugin
2820
}
2921

3022
/**
31-
* Load a plugin and run its activation function.
23+
* PluginAPI implements the plugin API described in typings/plugin.d.ts
24+
* Please see that file for details.
3225
*/
33-
const loadPlugin = async (pluginPath: string, app: Express, args: Args): Promise<void> => {
34-
try {
35-
const plugin: Plugin = require(pluginPath)
36-
plugin.activate(app, args)
37-
38-
const packageJson = require(path.join(pluginPath, "package.json"))
39-
logger.debug(
40-
"Loaded plugin",
41-
field("name", packageJson.name || path.basename(pluginPath)),
42-
field("path", pluginPath),
43-
field("version", packageJson.version || "n/a"),
26+
export class PluginAPI {
27+
private readonly plugins = new Array<Plugin>()
28+
private readonly logger: Logger
29+
30+
public constructor(
31+
logger: Logger,
32+
/**
33+
* These correspond to $CS_PLUGIN_PATH and $CS_PLUGIN respectively.
34+
*/
35+
private readonly csPlugin = "",
36+
private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`,
37+
){
38+
this.logger = logger.named("pluginapi")
39+
}
40+
41+
/**
42+
* applications grabs the full list of applications from
43+
* all loaded plugins.
44+
*/
45+
public async applications(): Promise<Application[]> {
46+
const apps = new Array<Application>()
47+
for (let p of this.plugins) {
48+
const pluginApps = await p.applications()
49+
50+
// TODO prevent duplicates
51+
// Add plugin key to each app.
52+
apps.push(
53+
...pluginApps.map((app) => {
54+
return { ...app, plugin: p }
55+
}),
56+
)
57+
}
58+
return apps
59+
}
60+
61+
/**
62+
* mount mounts all plugin routers onto r.
63+
*/
64+
public mount(r: express.Router): void {
65+
for (let p of this.plugins) {
66+
r.use(`/${p.name}`, p.router())
67+
}
68+
}
69+
70+
/**
71+
* loadPlugins loads all plugins based on this.csPluginPath
72+
* and this.csPlugin.
73+
*/
74+
public async loadPlugins(): Promise<void> {
75+
// Built-in plugins.
76+
await this._loadPlugins(path.join(__dirname, "../../plugins"))
77+
78+
for (let dir of this.csPluginPath.split(":")) {
79+
if (!dir) {
80+
continue
81+
}
82+
await this._loadPlugins(dir)
83+
}
84+
85+
for (let dir of this.csPlugin.split(":")) {
86+
if (!dir) {
87+
continue
88+
}
89+
await this.loadPlugin(dir)
90+
}
91+
}
92+
93+
private async _loadPlugins(dir: string): Promise<void> {
94+
try {
95+
const entries = await fsp.readdir(dir, { withFileTypes: true })
96+
for (let ent of entries) {
97+
if (!ent.isDirectory()) {
98+
continue
99+
}
100+
await this.loadPlugin(path.join(dir, ent.name))
101+
}
102+
} catch (err) {
103+
if (err.code !== "ENOENT") {
104+
this.logger.warn(`failed to load plugins from ${q(dir)}: ${err.message}`)
105+
}
106+
}
107+
}
108+
109+
private async loadPlugin(dir: string): Promise<void> {
110+
try {
111+
const str = await fsp.readFile(path.join(dir, "package.json"), {
112+
encoding: "utf8",
113+
})
114+
const packageJSON: PackageJSON = JSON.parse(str)
115+
const p = this._loadPlugin(dir, packageJSON)
116+
// TODO prevent duplicates
117+
this.plugins.push(p)
118+
} catch (err) {
119+
if (err.code !== "ENOENT") {
120+
this.logger.warn(`failed to load plugin: ${err.message}`)
121+
}
122+
}
123+
}
124+
125+
private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin {
126+
const logger = this.logger.named(packageJSON.name)
127+
logger.debug("loading plugin",
128+
field("plugin_dir", dir),
129+
field("package_json", packageJSON),
44130
)
45-
} catch (error) {
46-
logger.error(error.message)
131+
132+
if (!semver.satisfies(version, packageJSON.engines["code-server"])) {
133+
throw new Error(`plugin range ${q(packageJSON.engines["code-server"])} incompatible` +
134+
` with code-server version ${version}`)
135+
}
136+
if (!packageJSON.name) {
137+
throw new Error("plugin missing name")
138+
}
139+
if (!packageJSON.version) {
140+
throw new Error("plugin missing version")
141+
}
142+
if (!packageJSON.description) {
143+
throw new Error("plugin missing description")
144+
}
145+
146+
const p = {
147+
name: packageJSON.name,
148+
version: packageJSON.version,
149+
description: packageJSON.description,
150+
...require(dir),
151+
} as Plugin
152+
153+
p.init({
154+
logger: logger,
155+
})
156+
157+
logger.debug("loaded")
158+
159+
return p
47160
}
48161
}
49162

50-
/**
51-
* Load all plugins in the specified directory.
52-
*/
53-
const _loadPlugins = async (pluginDir: string, app: Express, args: Args): Promise<void> => {
54-
try {
55-
const files = await util.promisify(fs.readdir)(pluginDir, {
56-
withFileTypes: true,
57-
})
58-
await Promise.all(files.map((file) => loadPlugin(path.join(pluginDir, file.name), app, args)))
59-
} catch (error) {
60-
if (error.code !== "ENOENT") {
61-
logger.warn(error.message)
62-
}
163+
interface PackageJSON {
164+
name: string
165+
version: string
166+
description: string
167+
engines: {
168+
"code-server": string
63169
}
64170
}
65171

66-
/**
67-
* Load all plugins from the `plugins` directory, directories specified by
68-
* `CS_PLUGIN_PATH` (colon-separated), and individual plugins specified by
69-
* `CS_PLUGIN` (also colon-separated).
70-
*/
71-
export const loadPlugins = async (app: Express, args: Args): Promise<void> => {
72-
const pluginPath = process.env.CS_PLUGIN_PATH || `${path.join(paths.data, "plugins")}:/usr/share/code-server/plugins`
73-
const plugin = process.env.CS_PLUGIN || ""
74-
await Promise.all([
75-
// Built-in plugins.
76-
_loadPlugins(path.resolve(__dirname, "../../plugins"), app, args),
77-
// User-added plugins.
78-
...pluginPath
79-
.split(":")
80-
.filter((p) => !!p)
81-
.map((dir) => _loadPlugins(path.resolve(dir), app, args)),
82-
// Individual plugins so you don't have to symlink or move them into a
83-
// directory specifically for plugins. This lets you load plugins that are
84-
// on the same level as other directories that are not plugins (if you tried
85-
// to use CS_PLUGIN_PATH code-server would try to load those other
86-
// directories as plugins). Intended for development.
87-
...plugin
88-
.split(":")
89-
.filter((p) => !!p)
90-
.map((dir) => loadPlugin(path.resolve(dir), app, args)),
91-
])
172+
function q(s: string): string {
173+
if (s === undefined) {
174+
s = "undefined"
175+
}
176+
return JSON.stringify(s)
92177
}

src/node/routes/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { AuthType, DefaultedArgs } from "../cli"
1212
import { rootPath } from "../constants"
1313
import { Heart } from "../heart"
1414
import { replaceTemplates } from "../http"
15-
import { loadPlugins } from "../plugin"
15+
import { PluginAPI } from "../plugin"
1616
import * as domainProxy from "../proxy"
1717
import { getMediaMime, paths } from "../util"
1818
import * as health from "./health"
@@ -94,7 +94,9 @@ export const register = async (app: Express, server: http.Server, args: Defaulte
9494
app.use("/update", update.router)
9595
app.use("/vscode", vscode.router)
9696

97-
await loadPlugins(app, args)
97+
const papi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH)
98+
await papi.loadPlugins()
99+
papi.mount(app)
98100

99101
app.use(() => {
100102
throw new HttpError("Not Found", HttpCode.NotFound)

0 commit comments

Comments
 (0)