Archived
1
0

Refactor vscode endpoints to use fork directly.

This commit is contained in:
Teffen Ellis
2021-09-29 23:14:56 -04:00
committed by Teffen
parent beebf53adc
commit d8c344beda
52 changed files with 502 additions and 3406 deletions

45
src/node/routes/errors.ts Normal file
View File

@ -0,0 +1,45 @@
import { logger } from "@coder/logger"
import express from "express"
import { promises as fs } from "fs"
import path from "path"
import { WebsocketRequest } from "../../../typings/pluginapi"
import { HttpCode } from "../../common/http"
import { rootPath } from "../constants"
import { replaceTemplates } from "../http"
import { getMediaMime } from "../util"
const notFoundCodes = ["ENOENT", "EISDIR", "FileNotFound"]
export const errorHandler: express.ErrorRequestHandler = async (err, req, res, next) => {
if (notFoundCodes.includes(err.code)) {
err.status = HttpCode.NotFound
}
const status = err.status ?? err.statusCode ?? 500
res.status(status)
// Assume anything that explicitly accepts text/html is a user browsing a
// page (as opposed to an xhr request). Don't use `req.accepts()` since
// *every* request that I've seen (in Firefox and Chromium at least)
// includes `*/*` making it always truthy. Even for css/javascript.
if (req.headers.accept && req.headers.accept.includes("text/html")) {
const resourcePath = path.resolve(rootPath, "src/browser/pages/error.html")
res.set("Content-Type", getMediaMime(resourcePath))
const content = await fs.readFile(resourcePath, "utf8")
res.send(
replaceTemplates(req, content)
.replace(/{{ERROR_TITLE}}/g, status)
.replace(/{{ERROR_HEADER}}/g, status)
.replace(/{{ERROR_BODY}}/g, err.message),
)
} else {
res.json({
error: err.message,
...(err.details || {}),
})
}
}
export const wsErrorHandler: express.ErrorRequestHandler = async (err, req, res, next) => {
logger.error(`${err.message} ${err.stack}`)
;(req as WebsocketRequest).ws.end()
}

View File

@ -1,5 +1,4 @@
import { logger } from "@coder/logger"
import bodyParser from "body-parser"
import cookieParser from "cookie-parser"
import * as express from "express"
import { promises as fs } from "fs"
@ -10,22 +9,21 @@ 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 { commit, isDevMode, rootPath } from "../constants"
import { Heart } from "../heart"
import { ensureAuthenticated, redirect, replaceTemplates } from "../http"
import { ensureAuthenticated, redirect } from "../http"
import { PluginAPI } from "../plugin"
import { getMediaMime, paths } from "../util"
import { wrapper } from "../wrapper"
import * as apps from "./apps"
import * as domainProxy from "./domainProxy"
import { errorHandler, wsErrorHandler } from "./errors"
import * as health from "./health"
import * as login from "./login"
import * as logout from "./logout"
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"
import { createVSServerRouter, VSServerResult } from "./vscode"
/**
* Register all routes and middleware.
@ -124,13 +122,15 @@ export const register = async (
wrapper.onDispose(() => pluginApi.dispose())
}
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use("/", vscode.router)
wsApp.use("/", vscode.wsRouter.router)
app.use("/vscode", vscode.router)
wsApp.use("/vscode", vscode.wsRouter.router)
app.use(
"/_static",
express.static(rootPath, {
cacheControl: commit !== "development",
}),
)
app.use("/healthz", health.router)
wsApp.use("/healthz", health.wsRouter.router)
@ -143,49 +143,32 @@ export const register = async (
app.all("/logout", (req, res) => redirect(req, res, "/", {}))
}
app.use("/static", _static.router)
app.use("/update", update.router)
let vscode: VSServerResult
try {
vscode = await createVSServerRouter(args)
app.use("/", vscode.router)
wsApp.use("/", vscode.wsRouter.router)
app.use("/vscode", vscode.router)
wsApp.use("/vscode", vscode.wsRouter.router)
} catch (error: any) {
if (isDevMode) {
logger.warn(error)
logger.warn("VS Server router may still be compiling.")
} else {
throw error
}
}
server.on("close", () => {
vscode.vscodeServer.close()
})
app.use(() => {
throw new HttpError("Not Found", HttpCode.NotFound)
})
const errorHandler: express.ErrorRequestHandler = async (err, req, res, next) => {
if (err.code === "ENOENT" || err.code === "EISDIR") {
err.status = HttpCode.NotFound
}
const status = err.status ?? err.statusCode ?? 500
res.status(status)
// Assume anything that explicitly accepts text/html is a user browsing a
// page (as opposed to an xhr request). Don't use `req.accepts()` since
// *every* request that I've seen (in Firefox and Chromium at least)
// includes `*/*` making it always truthy. Even for css/javascript.
if (req.headers.accept && req.headers.accept.includes("text/html")) {
const resourcePath = path.resolve(rootPath, "src/browser/pages/error.html")
res.set("Content-Type", getMediaMime(resourcePath))
const content = await fs.readFile(resourcePath, "utf8")
res.send(
replaceTemplates(req, content)
.replace(/{{ERROR_TITLE}}/g, status)
.replace(/{{ERROR_HEADER}}/g, status)
.replace(/{{ERROR_BODY}}/g, err.message),
)
} else {
res.json({
error: err.message,
...(err.details || {}),
})
}
}
app.use(errorHandler)
const wsErrorHandler: express.ErrorRequestHandler = async (err, req, res, next) => {
logger.error(`${err.message} ${err.stack}`)
;(req as pluginapi.WebsocketRequest).ws.end()
}
wsApp.use(wsErrorHandler)
}

View File

@ -111,7 +111,7 @@ router.post("/", async (req, res) => {
)
throw new Error("Incorrect password")
} catch (error) {
} catch (error: any) {
const renderedHtml = await getRoot(req, error)
res.send(renderedHtml)
}

View File

@ -1,71 +0,0 @@
import { field, logger } from "@coder/logger"
import { Router } from "express"
import { promises as fs } from "fs"
import * as path from "path"
import { Readable } from "stream"
import * as tarFs from "tar-fs"
import * as zlib from "zlib"
import { HttpCode, HttpError } from "../../common/http"
import { getFirstString } from "../../common/util"
import { rootPath } from "../constants"
import { authenticated, ensureAuthenticated, replaceTemplates } from "../http"
import { getMediaMime, pathToFsPath } from "../util"
export const router = Router()
// The commit is for caching.
router.get("/(:commit)(/*)?", async (req, res) => {
// Used by VS Code to load extensions into the web worker.
const tar = getFirstString(req.query.tar)
if (tar) {
await ensureAuthenticated(req)
let stream: Readable = tarFs.pack(pathToFsPath(tar))
if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) {
logger.debug("gzipping tar", field("path", tar))
const compress = zlib.createGzip()
stream.pipe(compress)
stream.on("error", (error) => compress.destroy(error))
stream.on("close", () => compress.end())
stream = compress
res.header("content-encoding", "gzip")
}
res.set("Content-Type", "application/x-tar")
stream.on("close", () => res.end())
return stream.pipe(res)
}
// If not a tar use the remainder of the path to load the resource.
if (!req.params[0]) {
throw new HttpError("Not Found", HttpCode.NotFound)
}
const resourcePath = path.resolve(req.params[0])
// Make sure it's in code-server if you aren't authenticated. This lets
// unauthenticated users load the login assets.
const isAuthenticated = await authenticated(req)
if (!resourcePath.startsWith(rootPath) && !isAuthenticated) {
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
}
// Don't cache during development. - can also be used if you want to make a
// static request without caching.
if (req.params.commit !== "development" && req.params.commit !== "-") {
res.header("Cache-Control", "public, max-age=31536000")
}
// Without this the default is to use the directory the script loaded from.
if (req.headers["service-worker"]) {
res.header("service-worker-allowed", "/")
}
res.set("Content-Type", getMediaMime(resourcePath))
if (resourcePath.endsWith("manifest.json")) {
const content = await fs.readFile(resourcePath, "utf8")
return res.send(replaceTemplates(req, content))
}
const content = await fs.readFile(resourcePath)
return res.send(content)
})

View File

@ -1,232 +1,73 @@
import * as crypto from "crypto"
import { Request, Router } from "express"
import { promises as fs } from "fs"
import * as path from "path"
import qs from "qs"
import * as ipc from "../../../typings/ipc"
import { Emitter } from "../../common/emitter"
import { HttpCode, HttpError } from "../../common/http"
import { getFirstString } from "../../common/util"
import { Feature } from "../cli"
import { isDevMode, rootPath, version } from "../constants"
import { authenticated, ensureAuthenticated, redirect, replaceTemplates } from "../http"
import { getMediaMime, pathToFsPath } from "../util"
import { VscodeProvider } from "../vscode"
import { Router as WsRouter } from "../wsRouter"
import * as express from "express"
import { Server } from "http"
import path from "path"
import { AuthType, DefaultedArgs } from "../cli"
import { version as codeServerVersion, vsRootPath } from "../constants"
import { ensureAuthenticated } from "../http"
import { loadAMDModule } from "../util"
import { Router as WsRouter, WebsocketRouter } from "../wsRouter"
import { errorHandler } from "./errors"
export const router = Router()
const vscode = new VscodeProvider()
router.get("/", async (req, res) => {
const isAuthenticated = await authenticated(req)
if (!isAuthenticated) {
return redirect(req, res, "login", {
// req.baseUrl can be blank if already at the root.
to: req.baseUrl && req.baseUrl !== "/" ? req.baseUrl : undefined,
})
}
const [content, options] = await Promise.all([
await fs.readFile(path.join(rootPath, "src/browser/pages/vscode.html"), "utf8"),
(async () => {
try {
return await vscode.initialize({ args: req.args, remoteAuthority: req.headers.host || "" }, req.query)
} catch (error) {
const devMessage = isDevMode ? "It might not have finished compiling." : ""
throw new Error(`VS Code failed to load. ${devMessage} ${error.message}`)
}
})(),
])
options.productConfiguration.codeServerVersion = version
res.send(
replaceTemplates<ipc.Options>(
req,
// Uncomment prod blocks if not in development. TODO: Would this be
// better as a build step? Or maintain two HTML files again?
!isDevMode ? content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "") : content,
{
authed: req.args.auth !== "none",
disableUpdateCheck: !!req.args["disable-update-check"],
},
)
.replace(`"{{REMOTE_USER_DATA_URI}}"`, `'${JSON.stringify(options.remoteUserDataUri)}'`)
.replace(`"{{PRODUCT_CONFIGURATION}}"`, `'${JSON.stringify(options.productConfiguration)}'`)
.replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
.replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`),
)
})
/**
* TODO: Might currently be unused.
*/
router.get("/resource(/*)?", ensureAuthenticated, async (req, res) => {
const path = getFirstString(req.query.path)
if (path) {
res.set("Content-Type", getMediaMime(path))
res.send(await fs.readFile(pathToFsPath(path)))
}
})
/**
* Used by VS Code to load files.
*/
router.get("/vscode-remote-resource(/*)?", ensureAuthenticated, async (req, res) => {
const path = getFirstString(req.query.path)
if (path) {
res.set("Content-Type", getMediaMime(path))
res.send(await fs.readFile(pathToFsPath(path)))
}
})
/**
* VS Code webviews use these paths to load files and to load webview assets
* like HTML and JavaScript.
*/
router.get("/webview/*", ensureAuthenticated, async (req, res) => {
res.set("Content-Type", getMediaMime(req.path))
if (/^vscode-resource/.test(req.params[0])) {
return res.send(await fs.readFile(req.params[0].replace(/^vscode-resource(\/file)?/, "")))
}
return res.send(
await fs.readFile(path.join(vscode.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", req.params[0])),
)
})
interface Callback {
uri: {
scheme: string
authority?: string
path?: string
query?: string
fragment?: string
}
timeout: NodeJS.Timeout
export interface VSServerResult {
router: express.Router
wsRouter: WebsocketRouter
vscodeServer: Server
}
const callbacks = new Map<string, Callback>()
const callbackEmitter = new Emitter<{ id: string; callback: Callback }>()
export const createVSServerRouter = async (args: DefaultedArgs): Promise<VSServerResult> => {
// Delete `VSCODE_CWD` very early even before
// importing bootstrap files. We have seen
// reports where `code .` would use the wrong
// current working directory due to our variable
// somehow escaping to the parent shell
// (https://github.com/microsoft/vscode/issues/126399)
delete process.env["VSCODE_CWD"]
/**
* Get vscode-requestId from the query and throw if it's missing or invalid.
*/
const getRequestId = (req: Request): string => {
if (!req.query["vscode-requestId"]) {
throw new HttpError("vscode-requestId is missing", HttpCode.BadRequest)
}
const bootstrap = require(path.join(vsRootPath, "out", "bootstrap"))
const bootstrapNode = require(path.join(vsRootPath, "out", "bootstrap-node"))
const product = require(path.join(vsRootPath, "product.json"))
if (typeof req.query["vscode-requestId"] !== "string") {
throw new HttpError("vscode-requestId is not a string", HttpCode.BadRequest)
}
// Avoid Monkey Patches from Application Insights
bootstrap.avoidMonkeyPatchFromAppInsights()
return req.query["vscode-requestId"]
}
// Enable portable support
bootstrapNode.configurePortable(product)
// Matches VS Code's fetch timeout.
const fetchTimeout = 5 * 60 * 1000
// Enable ASAR support
bootstrap.enableASARSupport()
// The callback endpoints are used during authentication. A URI is stored on
// /callback and then fetched later on /fetch-callback.
// See ../../../lib/vscode/resources/web/code-web.js
router.get("/callback", ensureAuthenticated, async (req, res) => {
const uriKeys = [
"vscode-requestId",
"vscode-scheme",
"vscode-authority",
"vscode-path",
"vscode-query",
"vscode-fragment",
]
// Signal processes that we got launched as CLI
process.env["VSCODE_CLI"] = "1"
const id = getRequestId(req)
const vscodeServerMain = await loadAMDModule<CodeServerLib.CreateVSServer>("vs/server/entry", "createVSServer")
// Move any query variables that aren't URI keys into the URI's query
// (importantly, this will include the code for oauth).
const query: qs.ParsedQs = {}
for (const key in req.query) {
if (!uriKeys.includes(key)) {
query[key] = req.query[key]
}
}
const callback = {
uri: {
scheme: getFirstString(req.query["vscode-scheme"]) || "code-oss",
authority: getFirstString(req.query["vscode-authority"]),
path: getFirstString(req.query["vscode-path"]),
query: (getFirstString(req.query.query) || "") + "&" + qs.stringify(query),
fragment: getFirstString(req.query["vscode-fragment"]),
},
// Make sure the map doesn't leak if nothing fetches this URI.
timeout: setTimeout(() => callbacks.delete(id), fetchTimeout),
}
callbacks.set(id, callback)
callbackEmitter.emit({ id, callback })
res.sendFile(path.join(rootPath, "vendor/modules/code-oss-dev/resources/web/callback.html"))
})
router.get("/fetch-callback", ensureAuthenticated, async (req, res) => {
const id = getRequestId(req)
const send = (callback: Callback) => {
clearTimeout(callback.timeout)
callbacks.delete(id)
res.json(callback.uri)
}
const callback = callbacks.get(id)
if (callback) {
return send(callback)
}
// VS Code will try again if the route returns no content but it seems more
// efficient to just wait on this request for as long as possible?
const handler = callbackEmitter.event(({ id: emitId, callback }) => {
if (id === emitId) {
handler.dispose()
send(callback)
}
const serverUrl = new URL(`${args.cert ? "https" : "http"}://${args.host}:${args.port}`)
const vscodeServer = await vscodeServerMain({
codeServerVersion,
serverUrl,
args,
authed: args.auth !== AuthType.None,
disableUpdateCheck: !!args["disable-update-check"],
})
// If the client closes the connection.
req.on("close", () => handler.dispose())
})
const router = express.Router()
const wsRouter = WsRouter()
export const wsRouter = WsRouter()
router.all("*", ensureAuthenticated, (req, res, next) => {
req.on("error", (error) => errorHandler(error, req, res, next))
wsRouter.ws("/", ensureAuthenticated, async (req) => {
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
const reply = crypto
.createHash("sha1")
.update(req.headers["sec-websocket-key"] + magic)
.digest("base64")
vscodeServer.emit("request", req, res)
})
const responseHeaders = [
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${reply}`,
]
wsRouter.ws("/", ensureAuthenticated, (req) => {
vscodeServer.emit("upgrade", req, req.socket, req.head)
// See if the browser reports it supports web socket compression.
// TODO: Parse this header properly.
const extensions = req.headers["sec-websocket-extensions"]
const isCompressionSupported = extensions ? extensions.includes("permessage-deflate") : false
req.socket.resume()
})
// TODO: For now we only use compression if the user enables it.
const isCompressionEnabled = !!req.args.enable?.includes(Feature.PermessageDeflate)
const useCompression = isCompressionEnabled && isCompressionSupported
if (useCompression) {
// This response header tells the browser the server supports compression.
responseHeaders.push("Sec-WebSocket-Extensions: permessage-deflate; server_max_window_bits=15")
return {
router,
wsRouter,
vscodeServer,
}
req.ws.write(responseHeaders.join("\r\n") + "\r\n\r\n")
await vscode.sendWebsocket(req.ws, req.query, useCompression)
})
}