Provide WsRouter to plugins

This commit is contained in:
Asher 2021-01-20 14:11:08 -06:00
parent fb37473e72
commit 055e0ef9ec
No known key found for this signature in database
GPG Key ID: D63C1EF81242354A
8 changed files with 101 additions and 34 deletions

View File

@ -7,6 +7,7 @@ import * as pluginapi from "../../typings/pluginapi"
import { version } from "./constants" import { version } from "./constants"
import { proxy } from "./proxy" import { proxy } from "./proxy"
import * as util from "./util" import * as util from "./util"
import { Router as WsRouter, WebsocketRouter } from "./wsRouter"
const fsp = fs.promises const fsp = fs.promises
/** /**
@ -21,6 +22,7 @@ require("module")._load = function (request: string, parent: object, isMain: boo
express, express,
field, field,
proxy, proxy,
WsRouter,
} }
} }
return originalLoad.apply(this, [request, parent, isMain]) return originalLoad.apply(this, [request, parent, isMain])
@ -103,15 +105,17 @@ 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) { for (const [, p] of this.plugins) {
if (!p.router) { if (p.router) {
continue
}
r.use(`${p.routerPath}`, p.router()) r.use(`${p.routerPath}`, p.router())
} }
if (p.wsRouter) {
wr.use(`${p.routerPath}`, (p.wsRouter() as WebsocketRouter).router)
}
}
} }
/** /**

View File

@ -6,20 +6,20 @@ import { promises as fs } from "fs"
import http from "http" import http from "http"
import * as path from "path" import * as path from "path"
import * as tls from "tls" import * as tls from "tls"
import * as pluginapi from "../../../typings/pluginapi"
import { HttpCode, HttpError } from "../../common/http" import { HttpCode, HttpError } from "../../common/http"
import { plural } from "../../common/util" import { plural } from "../../common/util"
import { AuthType, DefaultedArgs } from "../cli" import { AuthType, DefaultedArgs } from "../cli"
import { rootPath } from "../constants" import { rootPath } from "../constants"
import { Heart } from "../heart" import { Heart } from "../heart"
import { replaceTemplates, redirect } from "../http" import { redirect, replaceTemplates } from "../http"
import { PluginAPI } from "../plugin" import { PluginAPI } from "../plugin"
import { getMediaMime, paths } from "../util" import { getMediaMime, paths } from "../util"
import { WebsocketRequest } from "../wsRouter"
import * as apps from "./apps" import * as apps from "./apps"
import * as domainProxy from "./domainProxy" import * as domainProxy from "./domainProxy"
import * as health from "./health" import * as health from "./health"
import * as login from "./login" import * as login from "./login"
import * as proxy from "./pathProxy" import * as pathProxy from "./pathProxy"
// static is a reserved keyword. // static is a reserved keyword.
import * as _static from "./static" import * as _static from "./static"
import * as update from "./update" import * as update from "./update"
@ -104,21 +104,21 @@ export const register = async (
wsApp.use("/", domainProxy.wsRouter.router) wsApp.use("/", domainProxy.wsRouter.router)
app.all("/proxy/(:port)(/*)?", (req, res) => { app.all("/proxy/(:port)(/*)?", (req, res) => {
proxy.proxy(req, res) pathProxy.proxy(req, res)
}) })
wsApp.get("/proxy/(:port)(/*)?", (req, res) => { wsApp.get("/proxy/(:port)(/*)?", (req) => {
proxy.wsProxy(req as WebsocketRequest) pathProxy.wsProxy(req as pluginapi.WebsocketRequest)
}) })
// These two routes pass through the path directly. // These two routes pass through the path directly.
// So the proxied app must be aware it is running // So the proxied app must be aware it is running
// under /absproxy/<someport>/ // under /absproxy/<someport>/
app.all("/absproxy/(:port)(/*)?", (req, res) => { app.all("/absproxy/(:port)(/*)?", (req, res) => {
proxy.proxy(req, res, { pathProxy.proxy(req, res, {
passthroughPath: true, passthroughPath: true,
}) })
}) })
wsApp.get("/absproxy/(:port)(/*)?", (req, res) => { wsApp.get("/absproxy/(:port)(/*)?", (req) => {
proxy.wsProxy(req as WebsocketRequest, { pathProxy.wsProxy(req as pluginapi.WebsocketRequest, {
passthroughPath: true, passthroughPath: true,
}) })
}) })
@ -146,7 +146,7 @@ export const register = async (
const papi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH) const papi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH)
await papi.loadPlugins() await papi.loadPlugins()
papi.mount(app) papi.mount(app, wsApp)
app.use("/api/applications", apps.router(papi)) app.use("/api/applications", apps.router(papi))
app.use(() => { app.use(() => {
@ -187,7 +187,7 @@ export const register = async (
const wsErrorHandler: express.ErrorRequestHandler = async (err, req, res, next) => { const wsErrorHandler: express.ErrorRequestHandler = async (err, req, res, next) => {
logger.error(`${err.message} ${err.stack}`) logger.error(`${err.message} ${err.stack}`)
;(req as WebsocketRequest).ws.end() ;(req as pluginapi.WebsocketRequest).ws.end()
} }
wsApp.use(wsErrorHandler) wsApp.use(wsErrorHandler)

View File

@ -1,11 +1,11 @@
import { Request, Response } from "express" import { Request, Response } from "express"
import * as path from "path" import * as path from "path"
import qs from "qs" import qs from "qs"
import * as pluginapi from "../../../typings/pluginapi"
import { HttpCode, HttpError } from "../../common/http" import { HttpCode, HttpError } from "../../common/http"
import { normalize } from "../../common/util" import { normalize } from "../../common/util"
import { authenticated, ensureAuthenticated, redirect } from "../http" import { authenticated, ensureAuthenticated, redirect } from "../http"
import { proxy as _proxy } from "../proxy" import { proxy as _proxy } from "../proxy"
import { WebsocketRequest } from "../wsRouter"
const getProxyTarget = (req: Request, passthroughPath?: boolean): string => { const getProxyTarget = (req: Request, passthroughPath?: boolean): string => {
if (passthroughPath) { if (passthroughPath) {
@ -46,7 +46,7 @@ export function proxy(
} }
export function wsProxy( export function wsProxy(
req: WebsocketRequest, req: pluginapi.WebsocketRequest,
opts?: { opts?: {
passthroughPath?: boolean passthroughPath?: boolean
}, },

View File

@ -1,7 +1,7 @@
import * as express from "express" import * as express from "express"
import * as expressCore from "express-serve-static-core" import * as expressCore from "express-serve-static-core"
import * as http from "http" import * as http from "http"
import * as net from "net" import * as pluginapi from "../../typings/pluginapi"
export const handleUpgrade = (app: express.Express, server: http.Server): void => { export const handleUpgrade = (app: express.Express, server: http.Server): void => {
server.on("upgrade", (req, socket, head) => { server.on("upgrade", (req, socket, head) => {
@ -20,31 +20,20 @@ export const handleUpgrade = (app: express.Express, server: http.Server): void =
}) })
} }
export interface WebsocketRequest extends express.Request { interface InternalWebsocketRequest extends pluginapi.WebsocketRequest {
ws: net.Socket
head: Buffer
}
interface InternalWebsocketRequest extends WebsocketRequest {
_ws_handled: boolean _ws_handled: boolean
} }
export type WebSocketHandler = (
req: WebsocketRequest,
res: express.Response,
next: express.NextFunction,
) => void | Promise<void>
export class WebsocketRouter { export class WebsocketRouter {
public readonly router = express.Router() public readonly router = express.Router()
public ws(route: expressCore.PathParams, ...handlers: WebSocketHandler[]): void { public ws(route: expressCore.PathParams, ...handlers: pluginapi.WebSocketHandler[]): void {
this.router.get( this.router.get(
route, route,
...handlers.map((handler) => { ...handlers.map((handler) => {
const wrapped: express.Handler = (req, res, next) => { const wrapped: express.Handler = (req, res, next) => {
;(req as InternalWebsocketRequest)._ws_handled = true ;(req as InternalWebsocketRequest)._ws_handled = true
return handler(req as WebsocketRequest, res, next) return handler(req as pluginapi.WebsocketRequest, res, next)
} }
return wrapped return wrapped
}), }),

View File

@ -1,7 +1,10 @@
import * as express from "express"
import * as http from "http" import * as http from "http"
import * as nodeFetch from "node-fetch" import * as nodeFetch from "node-fetch"
import Websocket from "ws"
import * as util from "../src/common/util" import * as util from "../src/common/util"
import { ensureAddress } from "../src/node/app" 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. // Perhaps an abstraction similar to this should be used in app.ts as well.
export class HttpServer { 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. * close cleans up the server.
*/ */
@ -62,6 +72,13 @@ export class HttpServer {
return nodeFetch.default(`${ensureAddress(this.hs)}${requestPath}`, opts) 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 { public port(): number {
const addr = this.hs.address() const addr = this.hs.address()
if (addr && typeof addr === "object") { if (addr && typeof addr === "object") {

View File

@ -21,11 +21,13 @@ describe("plugin", () => {
await papi.loadPlugins(false) await papi.loadPlugins(false)
const app = express.default() const app = express.default()
papi.mount(app) const wsApp = express.default()
papi.mount(app, wsApp)
app.use("/api/applications", apps.router(papi)) app.use("/api/applications", apps.router(papi))
s = new httpserver.HttpServer() s = new httpserver.HttpServer()
await s.listen(app) await s.listen(app)
s.listenUpgrade(wsApp)
}) })
afterAll(async () => { afterAll(async () => {
@ -70,4 +72,13 @@ describe("plugin", () => {
const body = await resp.text() const body = await resp.text()
expect(body).toBe(indexHTML) 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")
})
}) })

View File

@ -1,5 +1,8 @@
import * as cs from "code-server" import * as cs from "code-server"
import * as fspath from "path" import * as fspath from "path"
import Websocket from "ws"
const wss = new Websocket.Server({ noServer: true })
export const plugin: cs.Plugin = { export const plugin: cs.Plugin = {
displayName: "Test Plugin", displayName: "Test Plugin",
@ -22,6 +25,16 @@ export const plugin: cs.Plugin = {
return r return r
}, },
wsRouter() {
const wr = cs.WsRouter()
wr.ws("/test-app", (req) => {
wss.handleUpgrade(req, req.socket, req.head, (ws) => {
ws.send("hello")
})
})
return wr
},
applications() { applications() {
return [ return [
{ {

View File

@ -3,6 +3,9 @@
*/ */
import { field, Logger } from "@coder/logger" import { field, Logger } from "@coder/logger"
import * as express from "express" import * as express from "express"
import * as expressCore from "express-serve-static-core"
import ProxyServer from "http-proxy"
import * as net from "net"
/** /**
* Overlay * Overlay
@ -78,6 +81,27 @@ import * as express from "express"
* ] * ]
*/ */
export interface WebsocketRequest extends express.Request {
ws: net.Socket
head: Buffer
}
export type WebSocketHandler = (
req: WebsocketRequest,
res: express.Response,
next: express.NextFunction,
) => void | Promise<void>
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 Express import used by code-server. * The Express import used by code-server.
* *
@ -152,6 +176,15 @@ export interface Plugin {
*/ */
router?(): express.Router router?(): express.Router
/**
* Returns the plugin's websocket router.
*
* Mounted at <code-sever-root>/<plugin-path>
*
* If not present, the plugin provides no websockets.
*/
wsRouter?(): WebsocketRouter
/** /**
* code-server uses this to collect the list of applications that * code-server uses this to collect the list of applications that
* the plugin can currently provide. * the plugin can currently provide.