Skip to content

Plugin additions #2622

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 27 commits into from
Feb 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5f1fab7
Re-export logger field for plugins
code-asher Jan 13, 2021
a8e9287
Re-export express for plugins
code-asher Jan 13, 2021
f6b04c7
Expose proxy server to plugins
code-asher Jan 19, 2021
fb37473
Load only test plugin during tests
code-asher Jan 19, 2021
055e0ef
Provide WsRouter to plugins
code-asher Jan 20, 2021
3c6fac9
Wait for inner process to exit
code-asher Jan 20, 2021
017b1cc
Add deinit for plugins
code-asher Jan 20, 2021
3211eb1
Expose log level to plugins
code-asher Jan 20, 2021
00cfd9b
Add working directory to plugin config
code-asher Jan 21, 2021
f136a60
Note that we immediately pause websockets
code-asher Jan 22, 2021
b13db31
Add health websocket
code-asher Jan 28, 2021
5505959
Expose websocket server to plugins
code-asher Jan 28, 2021
150513f
Export Logger type
code-asher Jan 28, 2021
36aad9b
Move global express args definition
code-asher Jan 30, 2021
22d1945
Expose replaceTemplates to plugins
code-asher Jan 28, 2021
c78f56b
Expose HttpError to plugins
code-asher Jan 29, 2021
2fe3d57
Mount plugins before bodyParser
code-asher Feb 9, 2021
3226d50
Rename papi to pluginApi
code-asher Feb 9, 2021
2879bd4
Add type alias for required modules
code-asher Feb 9, 2021
9647d65
Add code-server alias to eslint
code-asher Feb 9, 2021
b881117
Expand working directory comment
code-asher Feb 9, 2021
e098df0
Fix code-server module not being provided in Jest
code-asher Feb 9, 2021
e4e0ac4
Don't load plugins in tests
code-asher Feb 9, 2021
2b1b3e6
Add eslint import alias resolver
code-asher Feb 9, 2021
4f16087
Resolve code-server from the root
code-asher Feb 9, 2021
3f837d3
Fix tests failing due to collisions in release
code-asher Feb 10, 2021
de9491d
Mark code-server as a virtual module
code-asher Feb 10, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ci/dev/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ main() {
# information. We must also run it from the root otherwise coverage will not
# include our source files.
cd "$OLDPWD"
./test/node_modules/.bin/jest "$@"
CS_DISABLE_PLUGINS=true ./test/node_modules/.bin/jest "$@"
}

main "$@"
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"doctoc": "^1.4.0",
"eslint": "^7.7.0",
"eslint-config-prettier": "^6.0.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-prettier": "^3.1.0",
"istanbul-badges-readme": "^1.2.0",
Expand All @@ -62,8 +63,8 @@
"stylelint": "^13.0.0",
"stylelint-config-recommended": "^3.0.0",
"ts-node": "^9.0.0",
"wtfnode": "^0.8.4",
"typescript": "^4.1.3"
"typescript": "^4.1.3",
"wtfnode": "^0.8.4"
},
"resolutions": {
"@types/node": "^12.12.7",
Expand Down Expand Up @@ -138,6 +139,9 @@
"global": {
"lines": 40
}
}
},
"modulePathIgnorePatterns": [
"<rootDir>/release"
]
}
}
13 changes: 12 additions & 1 deletion src/node/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@ import qs from "qs"
import safeCompare from "safe-compare"
import { HttpCode, HttpError } from "../common/http"
import { normalize, Options } from "../common/util"
import { AuthType } from "./cli"
import { AuthType, DefaultedArgs } from "./cli"
import { commit, rootPath } from "./constants"
import { Heart } from "./heart"
import { hash } from "./util"

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
export interface Request {
args: DefaultedArgs
heart: Heart
}
}
}

/**
* Replace common variable strings in HTML templates.
*/
Expand Down
71 changes: 61 additions & 10 deletions src/node/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
import { Logger, field } from "@coder/logger"
import { field, Level, Logger } from "@coder/logger"
import * as express from "express"
import * as fs from "fs"
import * as path from "path"
import * as semver from "semver"
import * as pluginapi from "../../typings/pluginapi"
import { HttpCode, HttpError } from "../common/http"
import { version } from "./constants"
import { replaceTemplates } from "./http"
import { proxy } from "./proxy"
import * as util from "./util"
import { Router as WsRouter, WebsocketRouter, wss } from "./wsRouter"
const fsp = fs.promises

// Represents a required module which could be anything.
type Module = any

/**
* 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
require("module")._load = function (request: string, parent: object, isMain: boolean): Module {
return request === "code-server" ? codeServer : originalLoad.apply(this, [request, parent, isMain])
}

/**
* The module you get when importing "code-server".
*/
export const codeServer = {
express,
field,
HttpCode,
HttpError,
Level,
proxy,
replaceTemplates,
WsRouter,
wss,
}

interface Plugin extends pluginapi.Plugin {
/**
* These fields are populated from the plugin's package.json
Expand All @@ -26,7 +57,7 @@ interface Application extends pluginapi.Application {
/*
* Clone of the above without functions.
*/
plugin: Omit<Plugin, "init" | "router" | "applications">
plugin: Omit<Plugin, "init" | "deinit" | "router" | "applications">
}

/**
Expand All @@ -44,6 +75,7 @@ export class PluginAPI {
*/
private readonly csPlugin = "",
private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`,
private readonly workingDirectory: string | undefined = undefined,
) {
this.logger = logger.named("pluginapi")
}
Expand Down Expand Up @@ -85,22 +117,24 @@ export class PluginAPI {
}

/**
* mount mounts all plugin routers onto r.
* mount mounts all plugin routers onto r and websocket routers onto wr.
*/
public mount(r: express.Router): void {
public mount(r: express.Router, wr: express.Router): void {
for (const [, p] of this.plugins) {
if (!p.router) {
continue
if (p.router) {
r.use(`${p.routerPath}`, p.router())
}
if (p.wsRouter) {
wr.use(`${p.routerPath}`, (p.wsRouter() as WebsocketRouter).router)
}
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> {
public async loadPlugins(loadBuiltin = true): Promise<void> {
for (const dir of this.csPlugin.split(":")) {
if (!dir) {
continue
Expand All @@ -115,8 +149,9 @@ export class PluginAPI {
await this._loadPlugins(dir)
}

// Built-in plugins.
await this._loadPlugins(path.join(__dirname, "../../plugins"))
if (loadBuiltin) {
await this._loadPlugins(path.join(__dirname, "../../plugins"))
}
}

/**
Expand Down Expand Up @@ -225,12 +260,28 @@ export class PluginAPI {

p.init({
logger: logger,
workingDirectory: this.workingDirectory,
})

logger.debug("loaded")

return p
}

public async dispose(): Promise<void> {
await Promise.all(
Array.from(this.plugins.values()).map(async (p) => {
if (!p.deinit) {
return
}
try {
await p.deinit()
} catch (error) {
this.logger.error("plugin failed to deinit", field("name", p.name), field("error", error.message))
}
}),
)
}
}

interface PackageJSON {
Expand Down
17 changes: 17 additions & 0 deletions src/node/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router } from "express"
import { wss, Router as WsRouter } from "../wsRouter"

export const router = Router()

Expand All @@ -8,3 +9,19 @@ router.get("/", (req, res) => {
lastHeartbeat: req.heart.lastHeartbeat,
})
})

export const wsRouter = WsRouter()

wsRouter.ws("/", async (req) => {
wss.handleUpgrade(req, req.socket, req.head, (ws) => {
ws.on("message", () => {
ws.send(
JSON.stringify({
event: "health",
status: req.heart.alive() ? "alive" : "expired",
lastHeartbeat: req.heart.lastHeartbeat,
}),
)
})
})
})
46 changes: 21 additions & 25 deletions src/node/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,26 @@ import { promises as fs } from "fs"
import http from "http"
import * as path from "path"
import * as tls from "tls"
import * as pluginapi from "../../../typings/pluginapi"
import { HttpCode, HttpError } from "../../common/http"
import { plural } from "../../common/util"
import { AuthType, DefaultedArgs } from "../cli"
import { rootPath } from "../constants"
import { Heart } from "../heart"
import { replaceTemplates, redirect } from "../http"
import { redirect, replaceTemplates } from "../http"
import { PluginAPI } from "../plugin"
import { getMediaMime, paths } from "../util"
import { WebsocketRequest } from "../wsRouter"
import { wrapper } from "../wrapper"
import * as apps from "./apps"
import * as domainProxy from "./domainProxy"
import * as health from "./health"
import * as login from "./login"
import * as proxy from "./pathProxy"
import * as pathProxy from "./pathProxy"
// static is a reserved keyword.
import * as _static from "./static"
import * as update from "./update"
import * as vscode from "./vscode"

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
export interface Request {
args: DefaultedArgs
heart: Heart
}
}
}

/**
* Register all routes and middleware.
*/
Expand Down Expand Up @@ -104,25 +95,34 @@ export const register = async (
wsApp.use("/", domainProxy.wsRouter.router)

app.all("/proxy/(:port)(/*)?", (req, res) => {
proxy.proxy(req, res)
pathProxy.proxy(req, res)
})
wsApp.get("/proxy/(:port)(/*)?", (req, res) => {
proxy.wsProxy(req as WebsocketRequest)
wsApp.get("/proxy/(:port)(/*)?", (req) => {
pathProxy.wsProxy(req as pluginapi.WebsocketRequest)
})
// These two routes pass through the path directly.
// So the proxied app must be aware it is running
// under /absproxy/<someport>/
app.all("/absproxy/(:port)(/*)?", (req, res) => {
proxy.proxy(req, res, {
pathProxy.proxy(req, res, {
passthroughPath: true,
})
})
wsApp.get("/absproxy/(:port)(/*)?", (req, res) => {
proxy.wsProxy(req as WebsocketRequest, {
wsApp.get("/absproxy/(:port)(/*)?", (req) => {
pathProxy.wsProxy(req as pluginapi.WebsocketRequest, {
passthroughPath: true,
})
})

if (!process.env.CS_DISABLE_PLUGINS) {
const workingDir = args._ && args._.length > 0 ? path.resolve(args._[args._.length - 1]) : undefined
const pluginApi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH, workingDir)
await pluginApi.loadPlugins()
pluginApi.mount(app, wsApp)
app.use("/api/applications", apps.router(pluginApi))
wrapper.onDispose(() => pluginApi.dispose())
}

app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))

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

app.use("/healthz", health.router)
wsApp.use("/healthz", health.wsRouter.router)

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

const papi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH)
await papi.loadPlugins()
papi.mount(app)
app.use("/api/applications", apps.router(papi))

app.use(() => {
throw new HttpError("Not Found", HttpCode.NotFound)
})
Expand Down Expand Up @@ -187,7 +183,7 @@ export const register = async (

const wsErrorHandler: express.ErrorRequestHandler = async (err, req, res, next) => {
logger.error(`${err.message} ${err.stack}`)
;(req as WebsocketRequest).ws.end()
;(req as pluginapi.WebsocketRequest).ws.end()
}

wsApp.use(wsErrorHandler)
Expand Down
4 changes: 2 additions & 2 deletions src/node/routes/pathProxy.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Request, Response } from "express"
import * as path from "path"
import qs from "qs"
import * as pluginapi from "../../../typings/pluginapi"
import { HttpCode, HttpError } from "../../common/http"
import { normalize } from "../../common/util"
import { authenticated, ensureAuthenticated, redirect } from "../http"
import { proxy as _proxy } from "../proxy"
import { WebsocketRequest } from "../wsRouter"

const getProxyTarget = (req: Request, passthroughPath?: boolean): string => {
if (passthroughPath) {
Expand Down Expand Up @@ -46,7 +46,7 @@ export function proxy(
}

export function wsProxy(
req: WebsocketRequest,
req: pluginapi.WebsocketRequest,
opts?: {
passthroughPath?: boolean
},
Expand Down
14 changes: 8 additions & 6 deletions src/node/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,7 @@ export class ParentProcess extends Process {
this.logStdoutStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stdout.log"), opts)
this.logStderrStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stderr.log"), opts)

this.onDispose(() => {
this.disposeChild()
})
this.onDispose(() => this.disposeChild())

this.onChildMessage((message) => {
switch (message.type) {
Expand All @@ -252,11 +250,15 @@ export class ParentProcess extends Process {
})
}

private disposeChild(): void {
private async disposeChild(): Promise<void> {
this.started = undefined
if (this.child) {
this.child.removeAllListeners()
this.child.kill()
const child = this.child
child.removeAllListeners()
child.kill()
// Wait for the child to exit otherwise its output will be lost which can
// be especially problematic if you're trying to debug why cleanup failed.
await new Promise((r) => child!.on("exit", r))
}
}

Expand Down
Loading