Archived
1
0

Simplify frontend

Just a login form and a list of applications. No modals or anything like
that.
This commit is contained in:
Asher
2020-02-13 16:38:05 -06:00
parent bf1be16d11
commit b8fa7da972
49 changed files with 984 additions and 1846 deletions

59
src/node/app/README.md Normal file
View File

@ -0,0 +1,59 @@
Implementation of [VS Code](https://code.visualstudio.com/) remote/web for use
in `code-server`.
## Docker
To debug Golang in VS Code using the
[ms-vscode-go extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.Go),
you need to add `--security-opt seccomp=unconfined` to your `docker run`
arguments when launching code-server with Docker. See
[#725](https://github.com/cdr/code-server/issues/725) for details.
## Known Issues
- Creating custom VS Code extensions and debugging them doesn't work.
- Extension profiling and tips are currently disabled.
## Extensions
`code-server` does not provide access to the official
[Visual Studio Marketplace](https://marketplace.visualstudio.com/vscode). Instead,
Coder has created a custom extension marketplace that we manage for open-source
extensions. If you want to use an extension with code-server that we do not have
in our marketplace please look for a release in the extensions repository,
contact us to see if we have one in the works or, if you build an extension
locally from open source, you can copy it to the `extensions` folder. If you
build one locally from open-source please contribute it to the project and let
us know so we can give you props! If you have your own custom marketplace, it is
possible to point code-server to it by setting the `SERVICE_URL` and `ITEM_URL`
environment variables.
## Development: upgrading VS Code
We patch VS Code to provide and fix some functionality. As the web portion of VS
Code matures, we'll be able to shrink and maybe even entirely eliminate our
patch. In the meantime, however, upgrading the VS Code version requires ensuring
that the patch still applies and has the intended effects.
If functionality doesn't depend on code from VS Code then it should be moved
into code-server otherwise it should be in the patch.
To generate a new patch, **stage all the changes** you want to be included in
the patch in the VS Code source, then run `yarn patch:generate` in this
directory.
Our changes include:
- Allow multiple extension directories (both user and built-in).
- Modify the loader, websocket, webview, service worker, and asset requests to
use the URL of the page as a base (and TLS if necessary for the websocket).
- Send client-side telemetry through the server.
- Make changing the display language work.
- Make it possible for us to load code on the client.
- Make extensions work in the browser.
- Fix getting permanently disconnected when you sleep or hibernate for a while.
- Make it possible to automatically update the binary.
## Future
- Run VS Code unit tests against our builds to ensure features work as expected.

304
src/node/app/api.ts Normal file
View File

@ -0,0 +1,304 @@
import { field, logger } from "@coder/logger"
import * as cp from "child_process"
import * as http from "http"
import * as net from "net"
import * as WebSocket from "ws"
import {
Application,
ApplicationsResponse,
ClientMessage,
RecentResponse,
ServerMessage,
SessionError,
SessionResponse,
} from "../../common/api"
import { ApiEndpoint, HttpCode } from "../../common/http"
import { normalize } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
import { findApplications, findWhitelistedApplications } from "./bin"
interface ServerSession {
process?: cp.ChildProcess
readonly app: Application
}
/**
* API HTTP provider.
*/
export class ApiHttpProvider extends HttpProvider {
private readonly ws = new WebSocket.Server({ noServer: true })
private readonly sessions = new Map<string, ServerSession>()
public constructor(options: HttpProviderOptions, private readonly server: HttpServer) {
super(options)
}
public dispose(): void {
this.sessions.forEach((s) => {
if (s.process) {
s.process.kill()
}
})
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
if (!this.authenticated(request)) {
return { code: HttpCode.Unauthorized }
}
switch (route.base) {
case ApiEndpoint.applications:
this.ensureMethod(request)
return {
content: {
applications: await this.applications(),
},
} as HttpResponse<ApplicationsResponse>
case ApiEndpoint.session:
return this.session(request)
case ApiEndpoint.recent:
this.ensureMethod(request)
return {
content: await this.recent(),
} as HttpResponse<RecentResponse>
}
return undefined
}
public async handleWebSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer
): Promise<true | undefined> {
if (!this.authenticated(request)) {
throw new Error("not authenticated")
}
switch (route.base) {
case ApiEndpoint.status:
return this.handleStatusSocket(request, socket, head)
case ApiEndpoint.run:
return this.handleRunSocket(route, request, socket, head)
}
return undefined
}
private async handleStatusSocket(request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<true> {
const getMessageResponse = async (event: "health"): Promise<ServerMessage> => {
switch (event) {
case "health":
return { event, connections: await this.server.getConnections() }
default:
throw new Error("unexpected message")
}
}
await new Promise<WebSocket>((resolve) => {
this.ws.handleUpgrade(request, socket, head, (ws) => {
const send = (event: ServerMessage): void => {
ws.send(JSON.stringify(event))
}
ws.on("message", (data) => {
logger.trace("got message", field("message", data))
try {
const message: ClientMessage = JSON.parse(data.toString())
getMessageResponse(message.event).then(send)
} catch (error) {
logger.error(error.message, field("message", data))
}
})
resolve()
})
})
return true
}
/**
* A socket that connects to a session.
*/
private async handleRunSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer
): Promise<true> {
const sessionId = route.requestPath.replace(/^\//, "")
logger.debug("connecting session", field("sessionId", sessionId))
const ws = await new Promise<WebSocket>((resolve, reject) => {
this.ws.handleUpgrade(request, socket, head, (socket) => {
socket.binaryType = "arraybuffer"
const session = this.sessions.get(sessionId)
if (!session) {
socket.close(SessionError.NotFound)
return reject(new Error("session not found"))
}
resolve(socket as WebSocket)
socket.on("error", (error) => {
socket.close(SessionError.FailedToStart)
logger.error("got error while connecting socket", field("error", error))
reject(error)
})
})
})
// Send ready message.
ws.send(
Buffer.from(
JSON.stringify({
protocol: "TODO",
})
)
)
return true
}
/**
* Return whitelisted applications.
*/
public async applications(): Promise<ReadonlyArray<Application>> {
return findWhitelistedApplications()
}
/**
* Return installed applications.
*/
public async installedApplications(): Promise<ReadonlyArray<Application>> {
return findApplications()
}
/**
* Get a running application.
*/
public getRunningApplication(sessionIdOrPath?: string): Application | undefined {
if (!sessionIdOrPath) {
return undefined
}
const sessionId = sessionIdOrPath.replace(/\//g, "")
let session = this.sessions.get(sessionId)
if (session) {
logger.debug("found application by session id", field("id", sessionId))
return session.app
}
const base = normalize("/" + sessionIdOrPath)
session = Array.from(this.sessions.values()).find((s) => s.app.path === base)
if (session) {
logger.debug("found application by path", field("path", base))
return session.app
}
logger.debug("no application found matching route", field("route", sessionIdOrPath))
return undefined
}
/**
* Handle /session endpoint.
*/
private async session(request: http.IncomingMessage): Promise<HttpResponse | undefined> {
this.ensureMethod(request, ["DELETE", "POST"])
const data = await this.getData(request)
if (!data) {
return undefined
}
switch (request.method) {
case "DELETE":
return this.deleteSession(JSON.parse(data).sessionId)
case "POST": {
// Prevent spawning the same app multiple times.
const parsed: Application = JSON.parse(data)
const app = this.getRunningApplication(parsed.sessionId || parsed.path)
if (app) {
return {
content: {
created: false,
sessionId: app.sessionId,
},
} as HttpResponse<SessionResponse>
}
return {
content: {
created: true,
sessionId: await this.createSession(parsed),
},
} as HttpResponse<SessionResponse>
}
}
return undefined
}
/**
* Kill a session identified by `app.sessionId`.
*/
public deleteSession(sessionId: string): HttpResponse {
logger.debug("deleting session", field("sessionId", sessionId))
const session = this.sessions.get(sessionId)
if (!session) {
throw new Error("session does not exist")
}
if (session.process) {
session.process.kill()
}
this.sessions.delete(sessionId)
return { code: HttpCode.Ok }
}
/**
* Create a new session and return the session ID.
*/
public async createSession(app: Application): Promise<string> {
const sessionId = Math.floor(Math.random() * 10000).toString()
if (this.sessions.has(sessionId)) {
throw new Error("conflicting session id")
}
if (!app.exec) {
throw new Error("cannot execute application with no `exec`")
}
const appSession: ServerSession = {
app: {
...app,
sessionId,
},
}
this.sessions.set(sessionId, appSession)
try {
throw new Error("TODO")
} catch (error) {
this.sessions.delete(sessionId)
throw error
}
}
/**
* Return recent sessions.
*/
public async recent(): Promise<RecentResponse> {
return {
recent: [], // TODO
running: Array.from(this.sessions).map(([sessionId, session]) => ({
...session.app,
sessionId,
})),
}
}
/**
* For these, just return the error message since they'll be requested as
* JSON.
*/
public async getErrorRoot(_route: Route, _title: string, _header: string, error: string): Promise<HttpResponse> {
return {
content: JSON.stringify({ error }),
}
}
}

130
src/node/app/app.ts Normal file
View File

@ -0,0 +1,130 @@
import { logger } from "@coder/logger"
import * as http from "http"
import * as querystring from "querystring"
import { Application } from "../../common/api"
import { HttpCode, HttpError } from "../../common/http"
import { Options } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { ApiHttpProvider } from "./api"
/**
* Top-level and fallback HTTP provider.
*/
export class MainHttpProvider extends HttpProvider {
public constructor(options: HttpProviderOptions, private readonly api: ApiHttpProvider) {
super(options)
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
switch (route.base) {
case "/static": {
this.ensureMethod(request)
const response = await this.getResource(this.rootPath, route.requestPath)
if (!this.isDev) {
response.cache = true
}
return response
}
case "/delete": {
this.ensureMethod(request, "POST")
const data = await this.getData(request)
const p = data ? querystring.parse(data) : {}
this.api.deleteSession(p.sessionId as string)
return { redirect: "/" }
}
case "/": {
this.ensureMethod(request)
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
} else if (!this.authenticated(request)) {
return { redirect: "/login" }
}
return this.getRoot(route)
}
}
// Run an existing app, but if it doesn't exist go ahead and start it.
let app = this.api.getRunningApplication(route.base)
let sessionId = app && app.sessionId
if (!app) {
app = (await this.api.installedApplications()).find((a) => a.path === route.base)
if (app) {
sessionId = await this.api.createSession(app)
}
}
if (sessionId) {
return this.getAppRoot(
route,
{
sessionId,
base: this.base(route),
logLevel: logger.level,
},
(app && app.name) || ""
)
}
return this.getErrorRoot(route, "404", "404", "Application not found")
}
public async getRoot(route: Route): Promise<HttpResponse> {
const recent = await this.api.recent()
const apps = await this.api.installedApplications()
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{APP_LIST:RUNNING}}/g, this.getAppRows(recent.running))
.replace(
/{{APP_LIST:EDITORS}}/g,
this.getAppRows(apps.filter((app) => app.categories && app.categories.includes("Editor")))
)
.replace(
/{{APP_LIST:OTHER}}/g,
this.getAppRows(apps.filter((app) => !app.categories || !app.categories.includes("Editor")))
)
return response
}
public async getAppRoot(route: Route, options: Options, name: string): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{APP_NAME}}/g, name)
.replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`)
return response
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
private getAppRows(apps: ReadonlyArray<Application>): string {
return apps.length > 0 ? apps.map((app) => this.getAppRow(app)).join("\n") : `<div class="none">None</div>`
}
private getAppRow(app: Application): string {
return `<div class="app-row">
<a class="open" href=".${app.path}">
${
app.icon
? `<img class="icon" src="data:image/png;base64,${app.icon}"></img>`
: `<div class="icon -missing"></div>`
}
<div class="name">${app.name}</div>
</a>
${
app.sessionId
? `<form class="kill-form" action="./delete" method="POST">
<input type="hidden" name="sessionId" value="${app.sessionId}">
<button class="kill" type="submit">Kill</button>
</form>`
: ""
}
</div>`
}
}

27
src/node/app/bin.ts Normal file
View File

@ -0,0 +1,27 @@
import * as path from "path"
import { Application } from "../../common/api"
const getVscodeVersion = (): string => {
try {
return require(path.resolve(__dirname, "../../../lib/vscode/package.json")).version
} catch (error) {
return "unknown"
}
}
export const Vscode: Application = {
categories: ["Editor"],
name: "VS Code",
path: "/vscode",
version: getVscodeVersion(),
}
export const findApplications = async (): Promise<ReadonlyArray<Application>> => {
const apps: Application[] = [Vscode]
return apps.sort((a, b): number => a.name.localeCompare(b.name))
}
export const findWhitelistedApplications = async (): Promise<ReadonlyArray<Application>> => {
return []
}

124
src/node/app/login.ts Normal file
View File

@ -0,0 +1,124 @@
import * as http from "http"
import * as querystring from "querystring"
import { HttpCode, HttpError } from "../../common/http"
import { AuthType, HttpProvider, HttpResponse, Route } from "../http"
import { hash } from "../util"
interface LoginPayload {
password?: string
/**
* Since we must set a cookie with an absolute path, we need to know the full
* base path.
*/
base?: string
}
/**
* Login HTTP provider.
*/
export class LoginHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
if (this.options.auth !== AuthType.Password) {
throw new HttpError("Not found", HttpCode.NotFound)
}
switch (route.base) {
case "/":
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
}
switch (request.method) {
case "POST":
return this.tryLogin(route, request)
default:
this.ensureMethod(request)
if (this.authenticated(request)) {
return {
redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/",
query: { to: undefined },
}
}
return this.getRoot(route)
}
}
return undefined
}
public async getRoot(route: Route, value?: string, error?: Error): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{VALUE}}/g, value || "")
.replace(/{{ERROR}}/g, error ? `<div class="error">${error.message}</div>` : "")
return response
}
/**
* Try logging in. On failure, show the login page with an error.
*/
private async tryLogin(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
// Already authenticated via cookies?
const providedPassword = this.authenticated(request)
if (providedPassword) {
return { code: HttpCode.Ok }
}
let payload: LoginPayload | undefined
try {
const data = await this.getData(request)
const p = data ? querystring.parse(data) : {}
payload = p
return await this.login(p, route, request)
} catch (error) {
return this.getRoot(route, payload ? payload.password : undefined, error)
}
}
/**
* Return a cookie if the user is authenticated otherwise throw an error.
*/
private async login(payload: LoginPayload, route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
const password = this.authenticated(request, {
key: typeof payload.password === "string" ? [hash(payload.password)] : undefined,
})
if (password) {
return {
redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/",
query: { to: undefined },
cookie:
typeof password === "string"
? {
key: "key",
value: password,
path: payload.base,
}
: undefined,
}
}
// Only log if it was an actual login attempt.
if (payload && payload.password) {
console.error(
"Failed login attempt",
JSON.stringify({
xForwardedFor: request.headers["x-forwarded-for"],
remoteAddress: request.connection.remoteAddress,
userAgent: request.headers["user-agent"],
timestamp: Math.floor(new Date().getTime() / 1000),
})
)
throw new Error("Incorrect password")
}
throw new Error("Missing password")
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
}

View File

@ -1,62 +0,0 @@
import { logger } from "@coder/logger"
import * as http from "http"
import * as React from "react"
import * as ReactDOMServer from "react-dom/server"
import App from "../../browser/app"
import { HttpCode, HttpError } from "../../common/http"
import { Options } from "../../common/util"
import { Vscode } from "../api/server"
import { HttpProvider, HttpResponse, Route } from "../http"
/**
* Top-level and fallback HTTP provider.
*/
export class MainHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
switch (route.base) {
case "/static": {
const response = await this.getResource(this.rootPath, route.requestPath)
if (!this.isDev) {
response.cache = true
}
return response
}
case "/vscode":
case "/": {
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
}
const options: Options = {
authed: !!this.authenticated(request),
basePath: this.base(route),
logLevel: logger.level,
}
// TODO: Load other apps based on the URL as well.
if (route.base === Vscode.path && options.authed) {
options.app = Vscode
}
return this.getRoot(route, options)
}
}
return undefined
}
public async getRoot(route: Route, options: Options): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/index.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`)
.replace(/{{COMPONENT}}/g, ReactDOMServer.renderToString(<App options={options} />))
return response
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
}

266
src/node/app/vscode.ts Normal file
View File

@ -0,0 +1,266 @@
import { field, logger } from "@coder/logger"
import * as cp from "child_process"
import * as crypto from "crypto"
import * as fs from "fs-extra"
import * as http from "http"
import * as net from "net"
import * as path from "path"
import * as url from "url"
import {
CodeServerMessage,
StartPath,
VscodeMessage,
VscodeOptions,
WorkbenchOptions,
} from "../../../lib/vscode/src/vs/server/ipc"
import { HttpCode, HttpError } from "../../common/http"
import { generateUuid } from "../../common/util"
import { Args } from "../cli"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { SettingsProvider } from "../settings"
import { xdgLocalDir } from "../util"
export interface Settings {
lastVisited: StartPath
}
export class VscodeHttpProvider extends HttpProvider {
private readonly serverRootPath: string
private readonly vsRootPath: string
private readonly settings = new SettingsProvider<Settings>(path.join(xdgLocalDir, "coder.json"))
private _vscode?: Promise<cp.ChildProcess>
private workbenchOptions?: WorkbenchOptions
public constructor(options: HttpProviderOptions, private readonly args: Args) {
super(options)
this.vsRootPath = path.resolve(this.rootPath, "lib/vscode")
this.serverRootPath = path.join(this.vsRootPath, "out/vs/server")
}
private async initialize(options: VscodeOptions): Promise<WorkbenchOptions> {
const id = generateUuid()
const vscode = await this.fork()
logger.debug("Setting up VS Code...")
return new Promise<WorkbenchOptions>((resolve, reject) => {
vscode.once("message", (message: VscodeMessage) => {
logger.debug("Got message from VS Code", field("message", message))
return message.type === "options" && message.id === id
? resolve(message.options)
: reject(new Error("Unexpected response during initialization"))
})
vscode.once("error", reject)
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
this.send({ type: "init", id, options }, vscode)
})
}
private fork(): Promise<cp.ChildProcess> {
if (!this._vscode) {
logger.debug("Forking VS Code...")
const vscode = cp.fork(path.join(this.serverRootPath, "fork"))
vscode.on("error", (error) => {
logger.error(error.message)
this._vscode = undefined
})
vscode.on("exit", (code) => {
logger.error(`VS Code exited unexpectedly with code ${code}`)
this._vscode = undefined
})
this._vscode = new Promise((resolve, reject) => {
vscode.once("message", (message: VscodeMessage) => {
logger.debug("Got message from VS Code", field("message", message))
return message.type === "ready"
? resolve(vscode)
: reject(new Error("Unexpected response waiting for ready response"))
})
vscode.once("error", reject)
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
})
}
return this._vscode
}
public async handleWebSocket(route: Route, request: http.IncomingMessage, socket: net.Socket): Promise<true> {
if (!this.authenticated(request)) {
throw new Error("not authenticated")
}
// VS Code expects a raw socket. It will handle all the web socket frames.
// We just need to handle the initial upgrade.
// This magic value is specified by the websocket spec.
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
const reply = crypto
.createHash("sha1")
.update(request.headers["sec-websocket-key"] + magic)
.digest("base64")
socket.write(
[
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${reply}`,
].join("\r\n") + "\r\n\r\n"
)
const vscode = await this._vscode
this.send({ type: "socket", query: route.query }, vscode, socket)
return true
}
private send(message: CodeServerMessage, vscode?: cp.ChildProcess, socket?: net.Socket): void {
if (!vscode || vscode.killed) {
throw new Error("vscode is not running")
}
vscode.send(message, socket)
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
this.ensureMethod(request)
switch (route.base) {
case "/":
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
} else if (!this.authenticated(request)) {
return { redirect: "/login", query: { to: this.options.base } }
}
try {
return await this.getRoot(request, route)
} catch (error) {
const message = `${
this.isDev ? "It might not have finished compiling (check for 'Finished compilation' in the output)." : ""
} <br><br>${error}`
return this.getErrorRoot(route, "VS Code failed to load", "VS Code failed to load", message)
}
}
this.ensureAuthenticated(request)
switch (route.base) {
case "/static": {
switch (route.requestPath) {
case "/out/vs/workbench/services/extensions/worker/extensionHostWorkerMain.js": {
const response = await this.getUtf8Resource(this.vsRootPath, route.requestPath)
response.content = response.content.replace(
/{{COMMIT}}/g,
this.workbenchOptions ? this.workbenchOptions.commit : ""
)
response.cache = true
return response
}
}
const response = await this.getResource(this.vsRootPath, route.requestPath)
response.cache = true
return response
}
case "/resource":
case "/vscode-remote-resource":
if (typeof route.query.path === "string") {
return this.getResource(route.query.path)
}
break
case "/tar":
if (typeof route.query.path === "string") {
return this.getTarredResource(route.query.path)
}
break
case "/webview":
if (/^\/vscode-resource/.test(route.requestPath)) {
return this.getResource(route.requestPath.replace(/^\/vscode-resource(\/file)?/, ""))
}
return this.getResource(this.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", route.requestPath)
}
return undefined
}
private async getRoot(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
const remoteAuthority = request.headers.host as string
const settings = await this.settings.read()
const startPath = await this.getFirstValidPath(
[
{ url: route.query.workspace, workspace: true },
{ url: route.query.folder, workspace: false },
settings.lastVisited,
this.args._ && this.args._.length > 0 ? { url: this.args._[0] } : undefined,
],
remoteAuthority
)
const [response, options] = await Promise.all([
await this.getUtf8Resource(this.rootPath, "src/browser/pages/vscode.html"),
this.initialize({
args: this.args,
remoteAuthority,
startPath,
}),
])
this.workbenchOptions = options
if (startPath) {
this.settings.write({
lastVisited: startPath,
})
}
if (!this.isDev) {
response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "")
}
return {
...response,
content: response.content
.replace(/{{COMMIT}}/g, options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{VS_BASE}}/g, this.base(route) + this.options.base)
.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)}'`),
}
}
/**
* Choose the first valid path. If `workspace` is undefined then either a
* workspace or a directory are acceptable. Otherwise it must be a file if a
* workspace or a directory otherwise.
*/
private async getFirstValidPath(
startPaths: Array<{ url?: string | string[]; workspace?: boolean } | undefined>,
remoteAuthority: string
): Promise<StartPath | undefined> {
for (let i = 0; i < startPaths.length; ++i) {
const startPath = startPaths[i]
if (!startPath) {
continue
}
const paths = typeof startPath.url === "string" ? [startPath.url] : startPath.url || []
for (let j = 0; j < paths.length; ++j) {
const uri = url.parse(paths[j])
try {
if (!uri.pathname) {
throw new Error(`${paths[j]} is not a valid URL`)
}
const stat = await fs.stat(uri.pathname)
if (typeof startPath.workspace === "undefined" || startPath.workspace !== stat.isDirectory()) {
return {
url: url.format({
protocol: uri.protocol || "vscode-remote",
hostname: remoteAuthority.split(":")[0],
port: remoteAuthority.split(":")[1],
pathname: uri.pathname,
slashes: true,
}),
workspace: !stat.isDirectory(),
}
}
} catch (error) {
logger.warn(error.message)
}
}
}
return undefined
}
}