Skip to content

Commit 8344e20

Browse files
authored
Merge pull request #2622 from cdr/plugin-additions
2 parents 662b5b2 + de9491d commit 8344e20

18 files changed

+308
-442
lines changed

ci/dev/test.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ main() {
99
# information. We must also run it from the root otherwise coverage will not
1010
# include our source files.
1111
cd "$OLDPWD"
12-
./test/node_modules/.bin/jest "$@"
12+
CS_DISABLE_PLUGINS=true ./test/node_modules/.bin/jest "$@"
1313
}
1414

1515
main "$@"

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"doctoc": "^1.4.0",
5555
"eslint": "^7.7.0",
5656
"eslint-config-prettier": "^6.0.0",
57+
"eslint-import-resolver-alias": "^1.1.2",
5758
"eslint-plugin-import": "^2.18.2",
5859
"eslint-plugin-prettier": "^3.1.0",
5960
"istanbul-badges-readme": "^1.2.0",
@@ -139,6 +140,9 @@
139140
"global": {
140141
"lines": 40
141142
}
142-
}
143+
},
144+
"modulePathIgnorePatterns": [
145+
"<rootDir>/release"
146+
]
143147
}
144148
}

src/node/http.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,21 @@ import qs from "qs"
55
import safeCompare from "safe-compare"
66
import { HttpCode, HttpError } from "../common/http"
77
import { normalize, Options } from "../common/util"
8-
import { AuthType } from "./cli"
8+
import { AuthType, DefaultedArgs } from "./cli"
99
import { commit, rootPath } from "./constants"
10+
import { Heart } from "./heart"
1011
import { hash } from "./util"
1112

13+
declare global {
14+
// eslint-disable-next-line @typescript-eslint/no-namespace
15+
namespace Express {
16+
export interface Request {
17+
args: DefaultedArgs
18+
heart: Heart
19+
}
20+
}
21+
}
22+
1223
/**
1324
* Replace common variable strings in HTML templates.
1425
*/

src/node/plugin.ts

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,44 @@
1-
import { Logger, field } from "@coder/logger"
1+
import { field, Level, Logger } from "@coder/logger"
22
import * as express from "express"
33
import * as fs from "fs"
44
import * as path from "path"
55
import * as semver from "semver"
66
import * as pluginapi from "../../typings/pluginapi"
7+
import { HttpCode, HttpError } from "../common/http"
78
import { version } from "./constants"
9+
import { replaceTemplates } from "./http"
10+
import { proxy } from "./proxy"
811
import * as util from "./util"
12+
import { Router as WsRouter, WebsocketRouter, wss } from "./wsRouter"
913
const fsp = fs.promises
1014

15+
// Represents a required module which could be anything.
16+
type Module = any
17+
18+
/**
19+
* Inject code-server when `require`d. This is required because the API provides
20+
* more than just types so these need to be provided at run-time.
21+
*/
22+
const originalLoad = require("module")._load
23+
require("module")._load = function (request: string, parent: object, isMain: boolean): Module {
24+
return request === "code-server" ? codeServer : originalLoad.apply(this, [request, parent, isMain])
25+
}
26+
27+
/**
28+
* The module you get when importing "code-server".
29+
*/
30+
export const codeServer = {
31+
express,
32+
field,
33+
HttpCode,
34+
HttpError,
35+
Level,
36+
proxy,
37+
replaceTemplates,
38+
WsRouter,
39+
wss,
40+
}
41+
1142
interface Plugin extends pluginapi.Plugin {
1243
/**
1344
* These fields are populated from the plugin's package.json
@@ -26,7 +57,7 @@ interface Application extends pluginapi.Application {
2657
/*
2758
* Clone of the above without functions.
2859
*/
29-
plugin: Omit<Plugin, "init" | "router" | "applications">
60+
plugin: Omit<Plugin, "init" | "deinit" | "router" | "applications">
3061
}
3162

3263
/**
@@ -44,6 +75,7 @@ export class PluginAPI {
4475
*/
4576
private readonly csPlugin = "",
4677
private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`,
78+
private readonly workingDirectory: string | undefined = undefined,
4779
) {
4880
this.logger = logger.named("pluginapi")
4981
}
@@ -85,22 +117,24 @@ export class PluginAPI {
85117
}
86118

87119
/**
88-
* mount mounts all plugin routers onto r.
120+
* mount mounts all plugin routers onto r and websocket routers onto wr.
89121
*/
90-
public mount(r: express.Router): void {
122+
public mount(r: express.Router, wr: express.Router): void {
91123
for (const [, p] of this.plugins) {
92-
if (!p.router) {
93-
continue
124+
if (p.router) {
125+
r.use(`${p.routerPath}`, p.router())
126+
}
127+
if (p.wsRouter) {
128+
wr.use(`${p.routerPath}`, (p.wsRouter() as WebsocketRouter).router)
94129
}
95-
r.use(`${p.routerPath}`, p.router())
96130
}
97131
}
98132

99133
/**
100134
* loadPlugins loads all plugins based on this.csPlugin,
101135
* this.csPluginPath and the built in plugins.
102136
*/
103-
public async loadPlugins(): Promise<void> {
137+
public async loadPlugins(loadBuiltin = true): Promise<void> {
104138
for (const dir of this.csPlugin.split(":")) {
105139
if (!dir) {
106140
continue
@@ -115,8 +149,9 @@ export class PluginAPI {
115149
await this._loadPlugins(dir)
116150
}
117151

118-
// Built-in plugins.
119-
await this._loadPlugins(path.join(__dirname, "../../plugins"))
152+
if (loadBuiltin) {
153+
await this._loadPlugins(path.join(__dirname, "../../plugins"))
154+
}
120155
}
121156

122157
/**
@@ -225,12 +260,28 @@ export class PluginAPI {
225260

226261
p.init({
227262
logger: logger,
263+
workingDirectory: this.workingDirectory,
228264
})
229265

230266
logger.debug("loaded")
231267

232268
return p
233269
}
270+
271+
public async dispose(): Promise<void> {
272+
await Promise.all(
273+
Array.from(this.plugins.values()).map(async (p) => {
274+
if (!p.deinit) {
275+
return
276+
}
277+
try {
278+
await p.deinit()
279+
} catch (error) {
280+
this.logger.error("plugin failed to deinit", field("name", p.name), field("error", error.message))
281+
}
282+
}),
283+
)
284+
}
234285
}
235286

236287
interface PackageJSON {

src/node/routes/health.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Router } from "express"
2+
import { wss, Router as WsRouter } from "../wsRouter"
23

34
export const router = Router()
45

@@ -8,3 +9,19 @@ router.get("/", (req, res) => {
89
lastHeartbeat: req.heart.lastHeartbeat,
910
})
1011
})
12+
13+
export const wsRouter = WsRouter()
14+
15+
wsRouter.ws("/", async (req) => {
16+
wss.handleUpgrade(req, req.socket, req.head, (ws) => {
17+
ws.on("message", () => {
18+
ws.send(
19+
JSON.stringify({
20+
event: "health",
21+
status: req.heart.alive() ? "alive" : "expired",
22+
lastHeartbeat: req.heart.lastHeartbeat,
23+
}),
24+
)
25+
})
26+
})
27+
})

src/node/routes/index.ts

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,26 @@ import { promises as fs } from "fs"
66
import http from "http"
77
import * as path from "path"
88
import * as tls from "tls"
9+
import * as pluginapi from "../../../typings/pluginapi"
910
import { HttpCode, HttpError } from "../../common/http"
1011
import { plural } from "../../common/util"
1112
import { AuthType, DefaultedArgs } from "../cli"
1213
import { rootPath } from "../constants"
1314
import { Heart } from "../heart"
14-
import { replaceTemplates, redirect } from "../http"
15+
import { redirect, replaceTemplates } from "../http"
1516
import { PluginAPI } from "../plugin"
1617
import { getMediaMime, paths } from "../util"
17-
import { WebsocketRequest } from "../wsRouter"
18+
import { wrapper } from "../wrapper"
1819
import * as apps from "./apps"
1920
import * as domainProxy from "./domainProxy"
2021
import * as health from "./health"
2122
import * as login from "./login"
22-
import * as proxy from "./pathProxy"
23+
import * as pathProxy from "./pathProxy"
2324
// static is a reserved keyword.
2425
import * as _static from "./static"
2526
import * as update from "./update"
2627
import * as vscode from "./vscode"
2728

28-
declare global {
29-
// eslint-disable-next-line @typescript-eslint/no-namespace
30-
namespace Express {
31-
export interface Request {
32-
args: DefaultedArgs
33-
heart: Heart
34-
}
35-
}
36-
}
37-
3829
/**
3930
* Register all routes and middleware.
4031
*/
@@ -104,25 +95,34 @@ export const register = async (
10495
wsApp.use("/", domainProxy.wsRouter.router)
10596

10697
app.all("/proxy/(:port)(/*)?", (req, res) => {
107-
proxy.proxy(req, res)
98+
pathProxy.proxy(req, res)
10899
})
109-
wsApp.get("/proxy/(:port)(/*)?", (req, res) => {
110-
proxy.wsProxy(req as WebsocketRequest)
100+
wsApp.get("/proxy/(:port)(/*)?", (req) => {
101+
pathProxy.wsProxy(req as pluginapi.WebsocketRequest)
111102
})
112103
// These two routes pass through the path directly.
113104
// So the proxied app must be aware it is running
114105
// under /absproxy/<someport>/
115106
app.all("/absproxy/(:port)(/*)?", (req, res) => {
116-
proxy.proxy(req, res, {
107+
pathProxy.proxy(req, res, {
117108
passthroughPath: true,
118109
})
119110
})
120-
wsApp.get("/absproxy/(:port)(/*)?", (req, res) => {
121-
proxy.wsProxy(req as WebsocketRequest, {
111+
wsApp.get("/absproxy/(:port)(/*)?", (req) => {
112+
pathProxy.wsProxy(req as pluginapi.WebsocketRequest, {
122113
passthroughPath: true,
123114
})
124115
})
125116

117+
if (!process.env.CS_DISABLE_PLUGINS) {
118+
const workingDir = args._ && args._.length > 0 ? path.resolve(args._[args._.length - 1]) : undefined
119+
const pluginApi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH, workingDir)
120+
await pluginApi.loadPlugins()
121+
pluginApi.mount(app, wsApp)
122+
app.use("/api/applications", apps.router(pluginApi))
123+
wrapper.onDispose(() => pluginApi.dispose())
124+
}
125+
126126
app.use(bodyParser.json())
127127
app.use(bodyParser.urlencoded({ extended: true }))
128128

@@ -132,6 +132,7 @@ export const register = async (
132132
wsApp.use("/vscode", vscode.wsRouter.router)
133133

134134
app.use("/healthz", health.router)
135+
wsApp.use("/healthz", health.wsRouter.router)
135136

136137
if (args.auth === AuthType.Password) {
137138
app.use("/login", login.router)
@@ -144,11 +145,6 @@ export const register = async (
144145
app.use("/static", _static.router)
145146
app.use("/update", update.router)
146147

147-
const papi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH)
148-
await papi.loadPlugins()
149-
papi.mount(app)
150-
app.use("/api/applications", apps.router(papi))
151-
152148
app.use(() => {
153149
throw new HttpError("Not Found", HttpCode.NotFound)
154150
})
@@ -187,7 +183,7 @@ export const register = async (
187183

188184
const wsErrorHandler: express.ErrorRequestHandler = async (err, req, res, next) => {
189185
logger.error(`${err.message} ${err.stack}`)
190-
;(req as WebsocketRequest).ws.end()
186+
;(req as pluginapi.WebsocketRequest).ws.end()
191187
}
192188

193189
wsApp.use(wsErrorHandler)

src/node/routes/pathProxy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Request, Response } from "express"
22
import * as path from "path"
33
import qs from "qs"
4+
import * as pluginapi from "../../../typings/pluginapi"
45
import { HttpCode, HttpError } from "../../common/http"
56
import { normalize } from "../../common/util"
67
import { authenticated, ensureAuthenticated, redirect } from "../http"
78
import { proxy as _proxy } from "../proxy"
8-
import { WebsocketRequest } from "../wsRouter"
99

1010
const getProxyTarget = (req: Request, passthroughPath?: boolean): string => {
1111
if (passthroughPath) {
@@ -46,7 +46,7 @@ export function proxy(
4646
}
4747

4848
export function wsProxy(
49-
req: WebsocketRequest,
49+
req: pluginapi.WebsocketRequest,
5050
opts?: {
5151
passthroughPath?: boolean
5252
},

src/node/wrapper.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,7 @@ export class ParentProcess extends Process {
234234
this.logStdoutStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stdout.log"), opts)
235235
this.logStderrStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stderr.log"), opts)
236236

237-
this.onDispose(() => {
238-
this.disposeChild()
239-
})
237+
this.onDispose(() => this.disposeChild())
240238

241239
this.onChildMessage((message) => {
242240
switch (message.type) {
@@ -252,11 +250,15 @@ export class ParentProcess extends Process {
252250
})
253251
}
254252

255-
private disposeChild(): void {
253+
private async disposeChild(): Promise<void> {
256254
this.started = undefined
257255
if (this.child) {
258-
this.child.removeAllListeners()
259-
this.child.kill()
256+
const child = this.child
257+
child.removeAllListeners()
258+
child.kill()
259+
// Wait for the child to exit otherwise its output will be lost which can
260+
// be especially problematic if you're trying to debug why cleanup failed.
261+
await new Promise((r) => child!.on("exit", r))
260262
}
261263
}
262264

0 commit comments

Comments
 (0)