Merge pull request #2622 from cdr/plugin-additions
This commit is contained in:
@ -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.
|
||||
*/
|
||||
|
@ -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
|
||||
@ -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">
|
||||
}
|
||||
|
||||
/**
|
||||
@ -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")
|
||||
}
|
||||
@ -85,14 +117,16 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,7 +134,7 @@ export class PluginAPI {
|
||||
* 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
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -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 {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Router } from "express"
|
||||
import { wss, Router as WsRouter } from "../wsRouter"
|
||||
|
||||
export const router = Router()
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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.
|
||||
*/
|
||||
@ -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 }))
|
||||
|
||||
@ -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)
|
||||
@ -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)
|
||||
})
|
||||
@ -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)
|
||||
|
@ -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) {
|
||||
@ -46,7 +46,7 @@ export function proxy(
|
||||
}
|
||||
|
||||
export function wsProxy(
|
||||
req: WebsocketRequest,
|
||||
req: pluginapi.WebsocketRequest,
|
||||
opts?: {
|
||||
passthroughPath?: boolean
|
||||
},
|
||||
|
@ -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) {
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import * as express from "express"
|
||||
import * as expressCore from "express-serve-static-core"
|
||||
import * as http from "http"
|
||||
import * as net from "net"
|
||||
import Websocket from "ws"
|
||||
import * as pluginapi from "../../typings/pluginapi"
|
||||
|
||||
export const handleUpgrade = (app: express.Express, server: http.Server): void => {
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
@ -20,31 +21,24 @@ export const handleUpgrade = (app: express.Express, server: http.Server): void =
|
||||
})
|
||||
}
|
||||
|
||||
export interface WebsocketRequest extends express.Request {
|
||||
ws: net.Socket
|
||||
head: Buffer
|
||||
}
|
||||
|
||||
interface InternalWebsocketRequest extends WebsocketRequest {
|
||||
interface InternalWebsocketRequest extends pluginapi.WebsocketRequest {
|
||||
_ws_handled: boolean
|
||||
}
|
||||
|
||||
export type WebSocketHandler = (
|
||||
req: WebsocketRequest,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => void | Promise<void>
|
||||
|
||||
export class WebsocketRouter {
|
||||
public readonly router = express.Router()
|
||||
|
||||
public ws(route: expressCore.PathParams, ...handlers: WebSocketHandler[]): void {
|
||||
/**
|
||||
* Handle a websocket at this route. Note that websockets are immediately
|
||||
* paused when they come in.
|
||||
*/
|
||||
public ws(route: expressCore.PathParams, ...handlers: pluginapi.WebSocketHandler[]): void {
|
||||
this.router.get(
|
||||
route,
|
||||
...handlers.map((handler) => {
|
||||
const wrapped: express.Handler = (req, res, next) => {
|
||||
;(req as InternalWebsocketRequest)._ws_handled = true
|
||||
return handler(req as WebsocketRequest, res, next)
|
||||
return handler(req as pluginapi.WebsocketRequest, res, next)
|
||||
}
|
||||
return wrapped
|
||||
}),
|
||||
@ -55,3 +49,5 @@ export class WebsocketRouter {
|
||||
export function Router(): WebsocketRouter {
|
||||
return new WebsocketRouter()
|
||||
}
|
||||
|
||||
export const wss = new Websocket.Server({ noServer: true })
|
||||
|
Reference in New Issue
Block a user