diff --git a/ci/dev/test.sh b/ci/dev/test.sh index 6b0acd02a..851aa0d3b 100755 --- a/ci/dev/test.sh +++ b/ci/dev/test.sh @@ -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 "$@" diff --git a/package.json b/package.json index 5409ba4a9..107d412d5 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,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", @@ -139,6 +140,9 @@ "global": { "lines": 40 } - } + }, + "modulePathIgnorePatterns": [ + "/release" + ] } } diff --git a/src/node/http.ts b/src/node/http.ts index 18fee9f84..eb8c91f94 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -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. */ diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 2c0519ac1..2ba1bf1eb 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -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: Omit } /** @@ -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 { + public async loadPlugins(loadBuiltin = true): Promise { 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 { + 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 { diff --git a/src/node/routes/health.ts b/src/node/routes/health.ts index 20dab71a5..f38bb0abd 100644 --- a/src/node/routes/health.ts +++ b/src/node/routes/health.ts @@ -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, + }), + ) + }) + }) +}) diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index dd4cc126a..d04eac349 100644 --- a/src/node/routes/index.ts +++ b/src/node/routes/index.ts @@ -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// 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) diff --git a/src/node/routes/pathProxy.ts b/src/node/routes/pathProxy.ts index 31fc53366..789fa5c18 100644 --- a/src/node/routes/pathProxy.ts +++ b/src/node/routes/pathProxy.ts @@ -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 }, diff --git a/src/node/wrapper.ts b/src/node/wrapper.ts index f6f84e2bd..28803fe9c 100644 --- a/src/node/wrapper.ts +++ b/src/node/wrapper.ts @@ -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 { 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)) } } diff --git a/src/node/wsRouter.ts b/src/node/wsRouter.ts index 8787d6f4f..d829d0821 100644 --- a/src/node/wsRouter.ts +++ b/src/node/wsRouter.ts @@ -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 - 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 }) diff --git a/test/httpserver.ts b/test/httpserver.ts index 50f887863..4fe54f880 100644 --- a/test/httpserver.ts +++ b/test/httpserver.ts @@ -1,7 +1,10 @@ +import * as express from "express" import * as http from "http" import * as nodeFetch from "node-fetch" +import Websocket from "ws" import * as util from "../src/common/util" import { ensureAddress } from "../src/node/app" +import { handleUpgrade } from "../src/node/wsRouter" // Perhaps an abstraction similar to this should be used in app.ts as well. export class HttpServer { @@ -39,6 +42,13 @@ export class HttpServer { }) } + /** + * Send upgrade requests to an Express app. + */ + public listenUpgrade(app: express.Express): void { + handleUpgrade(app, this.hs) + } + /** * close cleans up the server. */ @@ -62,6 +72,13 @@ export class HttpServer { return nodeFetch.default(`${ensureAddress(this.hs)}${requestPath}`, opts) } + /** + * Open a websocket against the requset path. + */ + public ws(requestPath: string): Websocket { + return new Websocket(`${ensureAddress(this.hs).replace("http:", "ws:")}${requestPath}`) + } + public port(): number { const addr = this.hs.address() if (addr && typeof addr === "object") { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 0c4acb9e8..dfd7fac00 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -2,11 +2,15 @@ import { logger } from "@coder/logger" import * as express from "express" import * as fs from "fs" import * as path from "path" -import { PluginAPI } from "../src/node/plugin" +import { HttpCode } from "../src/common/http" +import { codeServer, PluginAPI } from "../src/node/plugin" import * as apps from "../src/node/routes/apps" import * as httpserver from "./httpserver" const fsp = fs.promises +// Jest overrides `require` so our usual override doesn't work. +jest.mock("code-server", () => codeServer, { virtual: true }) + /** * Use $LOG_LEVEL=debug to see debug logs. */ @@ -15,15 +19,19 @@ describe("plugin", () => { let s: httpserver.HttpServer beforeAll(async () => { - papi = new PluginAPI(logger, `${path.resolve(__dirname, "test-plugin")}:meow`) - await papi.loadPlugins() + // Only include the test plugin to avoid contaminating results with other + // plugins that might be on the filesystem. + papi = new PluginAPI(logger, `${path.resolve(__dirname, "test-plugin")}:meow`, "") + await papi.loadPlugins(false) const app = express.default() - papi.mount(app) + const wsApp = express.default() + papi.mount(app, wsApp) app.use("/api/applications", apps.router(papi)) s = new httpserver.HttpServer() await s.listen(app) + s.listenUpgrade(wsApp) }) afterAll(async () => { @@ -68,4 +76,18 @@ describe("plugin", () => { const body = await resp.text() expect(body).toBe(indexHTML) }) + + it("/test-plugin/test-app (websocket)", async () => { + const ws = s.ws("/test-plugin/test-app") + const message = await new Promise((resolve) => { + ws.once("message", (message) => resolve(message)) + }) + ws.terminate() + expect(message).toBe("hello") + }) + + it("/test-plugin/error", async () => { + const resp = await s.fetch("/test-plugin/error") + expect(resp.status).toBe(HttpCode.LargePayload) + }) }) diff --git a/test/test-plugin/.eslintrc.yaml b/test/test-plugin/.eslintrc.yaml new file mode 100644 index 000000000..67a20fa64 --- /dev/null +++ b/test/test-plugin/.eslintrc.yaml @@ -0,0 +1,5 @@ +settings: + import/resolver: + alias: + map: + - [code-server, ./typings/pluginapi.d.ts] diff --git a/test/test-plugin/package.json b/test/test-plugin/package.json index 55c474e3d..2fe723780 100644 --- a/test/test-plugin/package.json +++ b/test/test-plugin/package.json @@ -12,8 +12,5 @@ }, "scripts": { "build": "tsc" - }, - "dependencies": { - "express": "^4.17.1" } } diff --git a/test/test-plugin/src/index.ts b/test/test-plugin/src/index.ts index fb1869447..592ad3723 100644 --- a/test/test-plugin/src/index.ts +++ b/test/test-plugin/src/index.ts @@ -1,8 +1,7 @@ -import * as express from "express" +import * as cs from "code-server" import * as fspath from "path" -import * as pluginapi from "../../../typings/pluginapi" -export const plugin: pluginapi.Plugin = { +export const plugin: cs.Plugin = { displayName: "Test Plugin", routerPath: "/test-plugin", homepageURL: "https://example.com", @@ -13,16 +12,29 @@ export const plugin: pluginapi.Plugin = { }, router() { - const r = express.Router() - r.get("/test-app", (req, res) => { + const r = cs.express.Router() + r.get("/test-app", (_, res) => { res.sendFile(fspath.resolve(__dirname, "../public/index.html")) }) - r.get("/goland/icon.svg", (req, res) => { + r.get("/goland/icon.svg", (_, res) => { res.sendFile(fspath.resolve(__dirname, "../public/icon.svg")) }) + r.get("/error", () => { + throw new cs.HttpError("error", cs.HttpCode.LargePayload) + }) return r }, + wsRouter() { + const wr = cs.WsRouter() + wr.ws("/test-app", (req) => { + cs.wss.handleUpgrade(req, req.socket, req.head, (ws) => { + ws.send("hello") + }) + }) + return wr + }, + applications() { return [ { diff --git a/test/test-plugin/tsconfig.json b/test/test-plugin/tsconfig.json index 0956ead88..5afea81bf 100644 --- a/test/test-plugin/tsconfig.json +++ b/test/test-plugin/tsconfig.json @@ -42,8 +42,10 @@ /* Module Resolution Options */ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ - // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ - // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, + "paths": { + "code-server": ["../../typings/pluginapi"] + } /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */, // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ diff --git a/test/test-plugin/yarn.lock b/test/test-plugin/yarn.lock index c77db2f7e..f295de1ea 100644 --- a/test/test-plugin/yarn.lock +++ b/test/test-plugin/yarn.lock @@ -64,372 +64,7 @@ "@types/mime" "*" "@types/node" "*" -accepts@~1.3.7: - version "1.3.7" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" - integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== - dependencies: - mime-types "~2.1.24" - negotiator "0.6.2" - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= - -body-parser@1.19.0: - version "1.19.0" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" - integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== - dependencies: - bytes "3.1.0" - content-type "~1.0.4" - debug "2.6.9" - depd "~1.1.2" - http-errors "1.7.2" - iconv-lite "0.4.24" - on-finished "~2.3.0" - qs "6.7.0" - raw-body "2.4.0" - type-is "~1.6.17" - -bytes@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" - integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== - -content-disposition@0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" - integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g== - dependencies: - safe-buffer "5.1.2" - -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= - -cookie@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" - integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -depd@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= - -destroy@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" - integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= - -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= - -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= - -express@^4.17.1: - version "4.17.1" - resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" - integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== - dependencies: - accepts "~1.3.7" - array-flatten "1.1.1" - body-parser "1.19.0" - content-disposition "0.5.3" - content-type "~1.0.4" - cookie "0.4.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "~1.1.2" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "~1.1.2" - fresh "0.5.2" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "~2.3.0" - parseurl "~1.3.3" - path-to-regexp "0.1.7" - proxy-addr "~2.0.5" - qs "6.7.0" - range-parser "~1.2.1" - safe-buffer "5.1.2" - send "0.17.1" - serve-static "1.14.1" - setprototypeof "1.1.1" - statuses "~1.5.0" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - -finalhandler@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" - integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "~2.3.0" - parseurl "~1.3.3" - statuses "~1.5.0" - unpipe "~1.0.0" - -forwarded@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" - integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= - -fresh@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= - -http-errors@1.7.2: - version "1.7.2" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" - integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg== - dependencies: - depd "~1.1.2" - inherits "2.0.3" - setprototypeof "1.1.1" - statuses ">= 1.5.0 < 2" - toidentifier "1.0.0" - -http-errors@~1.7.2: - version "1.7.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" - integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== - dependencies: - depd "~1.1.2" - inherits "2.0.4" - setprototypeof "1.1.1" - statuses ">= 1.5.0 < 2" - toidentifier "1.0.0" - -iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -inherits@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= - -inherits@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= - -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= - -mime-db@1.44.0: - version "1.44.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" - integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== - -mime-types@~2.1.24: - version "2.1.27" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" - integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== - dependencies: - mime-db "1.44.0" - -mime@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -ms@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" - integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== - -negotiator@0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" - integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== - -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= - dependencies: - ee-first "1.1.1" - -parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= - -proxy-addr@~2.0.5: - version "2.0.6" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" - integrity sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw== - dependencies: - forwarded "~0.1.2" - ipaddr.js "1.9.1" - -qs@6.7.0: - version "6.7.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" - integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== - -range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332" - integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q== - dependencies: - bytes "3.1.0" - http-errors "1.7.2" - iconv-lite "0.4.24" - unpipe "1.0.0" - -safe-buffer@5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -send@0.17.1: - version "0.17.1" - resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" - integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg== - dependencies: - debug "2.6.9" - depd "~1.1.2" - destroy "~1.0.4" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "~1.7.2" - mime "1.6.0" - ms "2.1.1" - on-finished "~2.3.0" - range-parser "~1.2.1" - statuses "~1.5.0" - -serve-static@1.14.1: - version "1.14.1" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" - integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.17.1" - -setprototypeof@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" - integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== - -"statuses@>= 1.5.0 < 2", statuses@~1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= - -toidentifier@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" - integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== - -type-is@~1.6.17, type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - typescript@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389" integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ== - -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= - -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= - -vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= diff --git a/typings/pluginapi.d.ts b/typings/pluginapi.d.ts index 06ce35fb4..b93a82b5d 100644 --- a/typings/pluginapi.d.ts +++ b/typings/pluginapi.d.ts @@ -1,8 +1,12 @@ /** * This file describes the code-server plugin API for adding new applications. */ -import { Logger } from "@coder/logger" +import { field, Level, Logger } from "@coder/logger" import * as express from "express" +import * as expressCore from "express-serve-static-core" +import ProxyServer from "http-proxy" +import * as net from "net" +import Websocket from "ws" /** * Overlay @@ -78,6 +82,75 @@ import * as express from "express" * ] */ +export enum HttpCode { + Ok = 200, + Redirect = 302, + NotFound = 404, + BadRequest = 400, + Unauthorized = 401, + LargePayload = 413, + ServerError = 500, +} + +export declare class HttpError extends Error { + constructor(message: string, status: HttpCode, details?: object) +} + +export interface WebsocketRequest extends express.Request { + ws: net.Socket + head: Buffer +} + +export type WebSocketHandler = ( + req: WebsocketRequest, + res: express.Response, + next: express.NextFunction, +) => void | Promise + +export interface WebsocketRouter { + readonly router: express.Router + ws(route: expressCore.PathParams, ...handlers: WebSocketHandler[]): void +} + +/** + * Create a router for websocket routes. + */ +export function WsRouter(): WebsocketRouter + +/** + * The websocket server used by code-server. + */ +export const wss: Websocket.Server + +/** + * The Express import used by code-server. + * + * Re-exported so plugins don't have to import duplicate copies of Express and + * to avoid potential version differences or issues caused by running separate + * instances. + */ +export { express } +/** + * Use to add a field to a log. + * + * Re-exported so plugins don't have to import duplicate copies of the logger. + */ +export { field, Level, Logger } + +/** + * code-server's proxy server. + */ +export const proxy: ProxyServer + +/** + * Replace variables in HTML: TO, BASE, CS_STATIC_BASE, and OPTIONS. + */ +export function replaceTemplates( + req: express.Request, + content: string, + extraOpts?: Omit, +): string + /** * Your plugin module must have a top level export "plugin" that implements this interface. * @@ -125,6 +198,11 @@ export interface Plugin { */ init(config: PluginConfig): void + /** + * Called when the plugin should dispose/shutdown everything. + */ + deinit?(): Promise + /** * Returns the plugin's router. * @@ -134,6 +212,15 @@ export interface Plugin { */ router?(): express.Router + /** + * Returns the plugin's websocket router. + * + * Mounted at / + * + * If not present, the plugin provides no websockets. + */ + wsRouter?(): WebsocketRouter + /** * code-server uses this to collect the list of applications that * the plugin can currently provide. @@ -156,6 +243,13 @@ export interface PluginConfig { * All plugin logs should be logged via this logger. */ readonly logger: Logger + + /** + * This can be specified by the user on the command line. Plugins should + * default to this directory when applicable. For example, the Jupyter plugin + * uses this to launch in this directory. + */ + readonly workingDirectory?: string } /** diff --git a/yarn.lock b/yarn.lock index 93173db81..b97c4b4fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2990,6 +2990,11 @@ eslint-config-prettier@^6.0.0: dependencies: get-stdin "^6.0.0" +eslint-import-resolver-alias@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz#297062890e31e4d6651eb5eba9534e1f6e68fc97" + integrity sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w== + eslint-import-resolver-node@^0.3.4: version "0.3.4" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717"