Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d969a5b

Browse files
authored
Merge pull request #2252 from cdr/plugin-5d60
Plugin API to add more applications to code-server
2 parents 9594970 + fe399ff commit d969a5b

17 files changed

+1176
-82
lines changed

ci/dev/test.sh

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ set -euo pipefail
44
main() {
55
cd "$(dirname "$0")/../.."
66

7-
mocha -r ts-node/register ./test/*.test.ts
7+
cd test/test-plugin
8+
make -s out/index.js
9+
cd "$OLDPWD"
10+
mocha -r ts-node/register ./test/*.test.ts "$@"
811
}
912

1013
main "$@"

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@types/safe-compare": "^1.1.0",
4444
"@types/semver": "^7.1.0",
4545
"@types/split2": "^2.1.6",
46+
"@types/supertest": "^2.0.10",
4647
"@types/tar-fs": "^2.0.0",
4748
"@types/tar-stream": "^2.1.0",
4849
"@types/ws": "^7.2.6",
@@ -59,6 +60,7 @@
5960
"prettier": "^2.0.5",
6061
"stylelint": "^13.0.0",
6162
"stylelint-config-recommended": "^3.0.0",
63+
"supertest": "^6.0.1",
6264
"ts-node": "^9.0.0",
6365
"typescript": "4.0.2"
6466
},

src/node/plugin.ts

+232-75
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,249 @@
1-
import { field, logger } from "@coder/logger"
2-
import { Express } from "express"
1+
import { Logger, field } from "@coder/logger"
2+
import * as express from "express"
33
import * as fs from "fs"
44
import * as path from "path"
5-
import * as util from "util"
6-
import { Args } from "./cli"
7-
import { paths } from "./util"
5+
import * as semver from "semver"
6+
import * as pluginapi from "../../typings/pluginapi"
7+
import { version } from "./constants"
8+
import * as util from "./util"
9+
const fsp = fs.promises
810

9-
/* eslint-disable @typescript-eslint/no-var-requires */
11+
interface Plugin extends pluginapi.Plugin {
12+
/**
13+
* These fields are populated from the plugin's package.json
14+
* and now guaranteed to exist.
15+
*/
16+
name: string
17+
version: string
1018

11-
export type Activate = (app: Express, args: Args) => void
12-
13-
/**
14-
* Plugins must implement this interface.
15-
*/
16-
export interface Plugin {
17-
activate: Activate
19+
/**
20+
* path to the node module on the disk.
21+
*/
22+
modulePath: string
1823
}
1924

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])
25+
interface Application extends pluginapi.Application {
26+
/*
27+
* Clone of the above without functions.
28+
*/
29+
plugin: Omit<Plugin, "init" | "router" | "applications">
2830
}
2931

3032
/**
31-
* Load a plugin and run its activation function.
33+
* PluginAPI implements the plugin API described in typings/pluginapi.d.ts
34+
* Please see that file for details.
3235
*/
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"),
44-
)
45-
} catch (error) {
46-
logger.error(error.message)
36+
export class PluginAPI {
37+
private readonly plugins = new Map<string, Plugin>()
38+
private readonly logger: Logger
39+
40+
public constructor(
41+
logger: Logger,
42+
/**
43+
* These correspond to $CS_PLUGIN_PATH and $CS_PLUGIN respectively.
44+
*/
45+
private readonly csPlugin = "",
46+
private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`,
47+
) {
48+
this.logger = logger.named("pluginapi")
4749
}
48-
}
4950

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)
51+
/**
52+
* applications grabs the full list of applications from
53+
* all loaded plugins.
54+
*/
55+
public async applications(): Promise<Application[]> {
56+
const apps = new Array<Application>()
57+
for (const [, p] of this.plugins) {
58+
if (!p.applications) {
59+
continue
60+
}
61+
const pluginApps = await p.applications()
62+
63+
// Add plugin key to each app.
64+
apps.push(
65+
...pluginApps.map((app) => {
66+
app = { ...app, path: path.join(p.routerPath, app.path || "") }
67+
app = { ...app, iconPath: path.join(app.path || "", app.iconPath) }
68+
return {
69+
...app,
70+
plugin: {
71+
name: p.name,
72+
version: p.version,
73+
modulePath: p.modulePath,
74+
75+
displayName: p.displayName,
76+
description: p.description,
77+
routerPath: p.routerPath,
78+
homepageURL: p.homepageURL,
79+
},
80+
}
81+
}),
82+
)
6283
}
84+
return apps
6385
}
64-
}
6586

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([
87+
/**
88+
* mount mounts all plugin routers onto r.
89+
*/
90+
public mount(r: express.Router): void {
91+
for (const [, p] of this.plugins) {
92+
if (!p.router) {
93+
continue
94+
}
95+
r.use(`${p.routerPath}`, p.router())
96+
}
97+
}
98+
99+
/**
100+
* loadPlugins loads all plugins based on this.csPlugin,
101+
* this.csPluginPath and the built in plugins.
102+
*/
103+
public async loadPlugins(): Promise<void> {
104+
for (const dir of this.csPlugin.split(":")) {
105+
if (!dir) {
106+
continue
107+
}
108+
await this.loadPlugin(dir)
109+
}
110+
111+
for (const dir of this.csPluginPath.split(":")) {
112+
if (!dir) {
113+
continue
114+
}
115+
await this._loadPlugins(dir)
116+
}
117+
75118
// 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-
])
119+
await this._loadPlugins(path.join(__dirname, "../../plugins"))
120+
}
121+
122+
/**
123+
* _loadPlugins is the counterpart to loadPlugins.
124+
*
125+
* It differs in that it loads all plugins in a single
126+
* directory whereas loadPlugins uses all available directories
127+
* as documented.
128+
*/
129+
private async _loadPlugins(dir: string): Promise<void> {
130+
try {
131+
const entries = await fsp.readdir(dir, { withFileTypes: true })
132+
for (const ent of entries) {
133+
if (!ent.isDirectory()) {
134+
continue
135+
}
136+
await this.loadPlugin(path.join(dir, ent.name))
137+
}
138+
} catch (err) {
139+
if (err.code !== "ENOENT") {
140+
this.logger.warn(`failed to load plugins from ${q(dir)}: ${err.message}`)
141+
}
142+
}
143+
}
144+
145+
private async loadPlugin(dir: string): Promise<void> {
146+
try {
147+
const str = await fsp.readFile(path.join(dir, "package.json"), {
148+
encoding: "utf8",
149+
})
150+
const packageJSON: PackageJSON = JSON.parse(str)
151+
for (const [, p] of this.plugins) {
152+
if (p.name === packageJSON.name) {
153+
this.logger.warn(
154+
`ignoring duplicate plugin ${q(p.name)} at ${q(dir)}, using previously loaded ${q(p.modulePath)}`,
155+
)
156+
return
157+
}
158+
}
159+
const p = this._loadPlugin(dir, packageJSON)
160+
this.plugins.set(p.name, p)
161+
} catch (err) {
162+
if (err.code !== "ENOENT") {
163+
this.logger.warn(`failed to load plugin: ${err.stack}`)
164+
}
165+
}
166+
}
167+
168+
/**
169+
* _loadPlugin is the counterpart to loadPlugin and actually
170+
* loads the plugin now that we know there is no duplicate
171+
* and that the package.json has been read.
172+
*/
173+
private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin {
174+
dir = path.resolve(dir)
175+
176+
const logger = this.logger.named(packageJSON.name)
177+
logger.debug("loading plugin", field("plugin_dir", dir), field("package_json", packageJSON))
178+
179+
if (!packageJSON.name) {
180+
throw new Error("plugin package.json missing name")
181+
}
182+
if (!packageJSON.version) {
183+
throw new Error("plugin package.json missing version")
184+
}
185+
if (!packageJSON.engines || !packageJSON.engines["code-server"]) {
186+
throw new Error(`plugin package.json missing code-server range like:
187+
"engines": {
188+
"code-server": "^3.6.0"
189+
}
190+
`)
191+
}
192+
if (!semver.satisfies(version, packageJSON.engines["code-server"])) {
193+
throw new Error(
194+
`plugin range ${q(packageJSON.engines["code-server"])} incompatible` + ` with code-server version ${version}`,
195+
)
196+
}
197+
198+
const pluginModule = require(dir)
199+
if (!pluginModule.plugin) {
200+
throw new Error("plugin module does not export a plugin")
201+
}
202+
203+
const p = {
204+
name: packageJSON.name,
205+
version: packageJSON.version,
206+
modulePath: dir,
207+
...pluginModule.plugin,
208+
} as Plugin
209+
210+
if (!p.displayName) {
211+
throw new Error("plugin missing displayName")
212+
}
213+
if (!p.description) {
214+
throw new Error("plugin missing description")
215+
}
216+
if (!p.routerPath) {
217+
throw new Error("plugin missing router path")
218+
}
219+
if (!p.routerPath.startsWith("/") || p.routerPath.length < 2) {
220+
throw new Error(`plugin router path ${q(p.routerPath)}: invalid`)
221+
}
222+
if (!p.homepageURL) {
223+
throw new Error("plugin missing homepage")
224+
}
225+
226+
p.init({
227+
logger: logger,
228+
})
229+
230+
logger.debug("loaded")
231+
232+
return p
233+
}
234+
}
235+
236+
interface PackageJSON {
237+
name: string
238+
version: string
239+
engines: {
240+
"code-server": string
241+
}
242+
}
243+
244+
function q(s: string | undefined): string {
245+
if (s === undefined) {
246+
s = "undefined"
247+
}
248+
return JSON.stringify(s)
92249
}

src/node/routes/apps.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as express from "express"
2+
import { PluginAPI } from "../plugin"
3+
4+
/**
5+
* Implements the /api/applications endpoint
6+
*
7+
* See typings/pluginapi.d.ts for details.
8+
*/
9+
export function router(papi: PluginAPI): express.Router {
10+
const router = express.Router()
11+
12+
router.get("/", async (req, res) => {
13+
res.json(await papi.applications())
14+
})
15+
16+
return router
17+
}

src/node/routes/index.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ 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 { getMediaMime, paths } from "../util"
1717
import { WebsocketRequest } from "../wsRouter"
18+
import * as apps from "./apps"
1819
import * as domainProxy from "./domainProxy"
1920
import * as health from "./health"
2021
import * as login from "./login"
@@ -115,7 +116,10 @@ export const register = async (
115116
app.use("/static", _static.router)
116117
app.use("/update", update.router)
117118

118-
await loadPlugins(app, args)
119+
const papi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH)
120+
await papi.loadPlugins()
121+
papi.mount(app)
122+
app.use("/api/applications", apps.router(papi))
119123

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

0 commit comments

Comments
 (0)