Simplify frontend
Just a login form and a list of applications. No modals or anything like that.
This commit is contained in:
59
src/node/app/README.md
Normal file
59
src/node/app/README.md
Normal 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 extension’s 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
304
src/node/app/api.ts
Normal 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
130
src/node/app/app.ts
Normal 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
27
src/node/app/bin.ts
Normal 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
124
src/node/app/login.ts
Normal 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
|
||||
}
|
||||
}
|
@ -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
266
src/node/app/vscode.ts
Normal 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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user