Merge pull request #2238 from cdr/code-asher/ch1385
This commit is contained in:
commit
5e603056fd
@ -31,6 +31,8 @@ rules:
|
|||||||
import/order:
|
import/order:
|
||||||
[error, { alphabetize: { order: "asc" }, groups: [["builtin", "external", "internal"], "parent", "sibling"] }]
|
[error, { alphabetize: { order: "asc" }, groups: [["builtin", "external", "internal"], "parent", "sibling"] }]
|
||||||
no-async-promise-executor: off
|
no-async-promise-executor: off
|
||||||
|
# This isn't a real module, just types, which apparently doesn't resolve.
|
||||||
|
import/no-unresolved: [error, { ignore: ["express-serve-static-core"] }]
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
# Does not work with CommonJS unfortunately.
|
# Does not work with CommonJS unfortunately.
|
||||||
|
@ -4,7 +4,10 @@ set -euo pipefail
|
|||||||
main() {
|
main() {
|
||||||
cd "$(dirname "$0")/../.."
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
mocha -r ts-node/register ./test/*.test.ts
|
cd test/test-plugin
|
||||||
|
make -s out/index.js
|
||||||
|
cd "$OLDPWD"
|
||||||
|
mocha -r ts-node/register ./test/*.test.ts "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
@ -210,6 +210,18 @@ index da4fa3e9d0443d679dfbab1000b434af2ae01afd..50f3e1144f8057883dea8b91ec2f7073
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processLib() {
|
function processLib() {
|
||||||
|
diff --git a/extensions/typescript-language-features/src/utils/platform.ts b/extensions/typescript-language-features/src/utils/platform.ts
|
||||||
|
index 2d754bf4054713f53beed030f9211b33532c1b4b..708b7e40a662e4ca93420992bf7a5af0c62ea5b2 100644
|
||||||
|
--- a/extensions/typescript-language-features/src/utils/platform.ts
|
||||||
|
+++ b/extensions/typescript-language-features/src/utils/platform.ts
|
||||||
|
@@ -6,6 +6,6 @@
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
export function isWeb(): boolean {
|
||||||
|
- // @ts-expect-error
|
||||||
|
+ // NOTE@coder: Remove unused ts-expect-error directive which causes tsc to error.
|
||||||
|
return typeof navigator !== 'undefined' && vscode.env.uiKind === vscode.UIKind.Web;
|
||||||
|
}
|
||||||
diff --git a/package.json b/package.json
|
diff --git a/package.json b/package.json
|
||||||
index 770b44b0c1ff53d903b7680ede27715376df00f2..b27ab71647a3e7c4b6076ba4fdb8fde20fa73bb0 100644
|
index 770b44b0c1ff53d903b7680ede27715376df00f2..b27ab71647a3e7c4b6076ba4fdb8fde20fa73bb0 100644
|
||||||
--- a/package.json
|
--- a/package.json
|
||||||
@ -1319,7 +1331,7 @@ index 0000000000000000000000000000000000000000..56331ff1fc32bbd82e769aaecb551e42
|
|||||||
+require('../../bootstrap-amd').load('vs/server/entry');
|
+require('../../bootstrap-amd').load('vs/server/entry');
|
||||||
diff --git a/src/vs/server/ipc.d.ts b/src/vs/server/ipc.d.ts
|
diff --git a/src/vs/server/ipc.d.ts b/src/vs/server/ipc.d.ts
|
||||||
new file mode 100644
|
new file mode 100644
|
||||||
index 0000000000000000000000000000000000000000..33b28cf2d53746ee9c50c056ac2e087dcee0a4e2
|
index 0000000000000000000000000000000000000000..6ce56bec114a6d8daf5dd3ded945ea78fc72a5c6
|
||||||
--- /dev/null
|
--- /dev/null
|
||||||
+++ b/src/vs/server/ipc.d.ts
|
+++ b/src/vs/server/ipc.d.ts
|
||||||
@@ -0,0 +1,131 @@
|
@@ -0,0 +1,131 @@
|
||||||
@ -1337,7 +1349,7 @@ index 0000000000000000000000000000000000000000..33b28cf2d53746ee9c50c056ac2e087d
|
|||||||
+ options: VscodeOptions;
|
+ options: VscodeOptions;
|
||||||
+}
|
+}
|
||||||
+
|
+
|
||||||
+export type Query = { [key: string]: string | string[] | undefined };
|
+export type Query = { [key: string]: string | string[] | undefined | Query | Query[] };
|
||||||
+
|
+
|
||||||
+export interface SocketMessage {
|
+export interface SocketMessage {
|
||||||
+ type: 'socket';
|
+ type: 'socket';
|
||||||
|
@ -30,6 +30,9 @@
|
|||||||
},
|
},
|
||||||
"main": "out/node/entry.js",
|
"main": "out/node/entry.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/body-parser": "^1.19.0",
|
||||||
|
"@types/cookie-parser": "^1.4.2",
|
||||||
|
"@types/express": "^4.17.8",
|
||||||
"@types/fs-extra": "^8.0.1",
|
"@types/fs-extra": "^8.0.1",
|
||||||
"@types/http-proxy": "^1.17.4",
|
"@types/http-proxy": "^1.17.4",
|
||||||
"@types/js-yaml": "^3.12.3",
|
"@types/js-yaml": "^3.12.3",
|
||||||
@ -40,6 +43,7 @@
|
|||||||
"@types/safe-compare": "^1.1.0",
|
"@types/safe-compare": "^1.1.0",
|
||||||
"@types/semver": "^7.1.0",
|
"@types/semver": "^7.1.0",
|
||||||
"@types/split2": "^2.1.6",
|
"@types/split2": "^2.1.6",
|
||||||
|
"@types/supertest": "^2.0.10",
|
||||||
"@types/tar-fs": "^2.0.0",
|
"@types/tar-fs": "^2.0.0",
|
||||||
"@types/tar-stream": "^2.1.0",
|
"@types/tar-stream": "^2.1.0",
|
||||||
"@types/ws": "^7.2.6",
|
"@types/ws": "^7.2.6",
|
||||||
@ -56,6 +60,7 @@
|
|||||||
"prettier": "^2.0.5",
|
"prettier": "^2.0.5",
|
||||||
"stylelint": "^13.0.0",
|
"stylelint": "^13.0.0",
|
||||||
"stylelint-config-recommended": "^3.0.0",
|
"stylelint-config-recommended": "^3.0.0",
|
||||||
|
"supertest": "^6.0.1",
|
||||||
"ts-node": "^9.0.0",
|
"ts-node": "^9.0.0",
|
||||||
"typescript": "4.0.2"
|
"typescript": "4.0.2"
|
||||||
},
|
},
|
||||||
@ -66,13 +71,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@coder/logger": "1.1.16",
|
"@coder/logger": "1.1.16",
|
||||||
|
"body-parser": "^1.19.0",
|
||||||
|
"cookie-parser": "^1.4.5",
|
||||||
"env-paths": "^2.2.0",
|
"env-paths": "^2.2.0",
|
||||||
|
"express": "^5.0.0-alpha.8",
|
||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^9.0.1",
|
||||||
"http-proxy": "^1.18.0",
|
"http-proxy": "^1.18.0",
|
||||||
"httpolyglot": "^0.1.2",
|
"httpolyglot": "^0.1.2",
|
||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"limiter": "^1.1.5",
|
"limiter": "^1.1.5",
|
||||||
"pem": "^1.14.2",
|
"pem": "^1.14.2",
|
||||||
|
"qs": "6.7.0",
|
||||||
"rotating-file-stream": "^2.1.1",
|
"rotating-file-stream": "^2.1.1",
|
||||||
"safe-buffer": "^5.1.1",
|
"safe-buffer": "^5.1.1",
|
||||||
"safe-compare": "^1.1.4",
|
"safe-compare": "^1.1.4",
|
||||||
|
@ -1,4 +1,10 @@
|
|||||||
import { Callback } from "./types"
|
import { logger } from "@coder/logger"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event emitter callback. Called with the emitted value and a promise that
|
||||||
|
* resolves when all emitters have finished.
|
||||||
|
*/
|
||||||
|
export type Callback<T, R = void | Promise<void>> = (t: T, p: Promise<void>) => R
|
||||||
|
|
||||||
export interface Disposable {
|
export interface Disposable {
|
||||||
dispose(): void
|
dispose(): void
|
||||||
@ -32,8 +38,21 @@ export class Emitter<T> {
|
|||||||
/**
|
/**
|
||||||
* Emit an event with a value.
|
* Emit an event with a value.
|
||||||
*/
|
*/
|
||||||
public emit(value: T): void {
|
public async emit(value: T): Promise<void> {
|
||||||
this.listeners.forEach((cb) => cb(value))
|
let resolve: () => void
|
||||||
|
const promise = new Promise<void>((r) => (resolve = r))
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
this.listeners.map(async (cb) => {
|
||||||
|
try {
|
||||||
|
await cb(value, promise)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error.message)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
resolve!()
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
@ -8,8 +8,12 @@ export enum HttpCode {
|
|||||||
ServerError = 500,
|
ServerError = 500,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an error with a message and an HTTP status code. This code will be
|
||||||
|
* used in the HTTP response.
|
||||||
|
*/
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
public constructor(message: string, public readonly code: number, public readonly details?: object) {
|
public constructor(message: string, public readonly status: HttpCode, public readonly details?: object) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = this.constructor.name
|
this.name = this.constructor.name
|
||||||
}
|
}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export type Callback<T, R = void> = (t: T) => R
|
|
61
src/node/app.ts
Normal file
61
src/node/app.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { logger } from "@coder/logger"
|
||||||
|
import express, { Express } from "express"
|
||||||
|
import { promises as fs } from "fs"
|
||||||
|
import http from "http"
|
||||||
|
import * as httpolyglot from "httpolyglot"
|
||||||
|
import { DefaultedArgs } from "./cli"
|
||||||
|
import { handleUpgrade } from "./wsRouter"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Express app and an HTTP/S server to serve it.
|
||||||
|
*/
|
||||||
|
export const createApp = async (args: DefaultedArgs): Promise<[Express, Express, http.Server]> => {
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
const server = args.cert
|
||||||
|
? httpolyglot.createServer(
|
||||||
|
{
|
||||||
|
cert: args.cert && (await fs.readFile(args.cert.value)),
|
||||||
|
key: args["cert-key"] && (await fs.readFile(args["cert-key"])),
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
)
|
||||||
|
: http.createServer(app)
|
||||||
|
|
||||||
|
await new Promise<http.Server>(async (resolve, reject) => {
|
||||||
|
server.on("error", reject)
|
||||||
|
if (args.socket) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(args.socket)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") {
|
||||||
|
logger.error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.listen(args.socket, resolve)
|
||||||
|
} else {
|
||||||
|
// [] is the correct format when using :: but Node errors with them.
|
||||||
|
server.listen(args.port, args.host.replace(/^\[|\]$/g, ""), resolve)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const wsApp = express()
|
||||||
|
handleUpgrade(wsApp, server)
|
||||||
|
|
||||||
|
return [app, wsApp, server]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the address of a server as a string (protocol *is* included) while
|
||||||
|
* ensuring there is one (will throw if there isn't).
|
||||||
|
*/
|
||||||
|
export const ensureAddress = (server: http.Server): string => {
|
||||||
|
const addr = server.address()
|
||||||
|
if (!addr) {
|
||||||
|
throw new Error("server has no address")
|
||||||
|
}
|
||||||
|
if (typeof addr !== "string") {
|
||||||
|
return `http://${addr.address}:${addr.port}`
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
@ -1,21 +0,0 @@
|
|||||||
import { HttpProvider, HttpResponse, Heart, HttpProviderOptions } from "../http"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the heartbeat.
|
|
||||||
*/
|
|
||||||
export class HealthHttpProvider extends HttpProvider {
|
|
||||||
public constructor(options: HttpProviderOptions, private readonly heart: Heart) {
|
|
||||||
super(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleRequest(): Promise<HttpResponse> {
|
|
||||||
return {
|
|
||||||
cache: false,
|
|
||||||
mime: "application/json",
|
|
||||||
content: {
|
|
||||||
status: this.heart.alive() ? "alive" : "expired",
|
|
||||||
lastHeartbeat: this.heart.lastHeartbeat,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,144 +0,0 @@
|
|||||||
import * as http from "http"
|
|
||||||
import * as limiter from "limiter"
|
|
||||||
import * as querystring from "querystring"
|
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
|
||||||
import { AuthType, HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
|
||||||
import { hash, humanPath } 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 constructor(
|
|
||||||
options: HttpProviderOptions,
|
|
||||||
private readonly configFile: string,
|
|
||||||
private readonly envPassword: boolean,
|
|
||||||
) {
|
|
||||||
super(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
if (this.options.auth !== AuthType.Password || !this.isRoot(route)) {
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
}
|
|
||||||
switch (route.base) {
|
|
||||||
case "/":
|
|
||||||
switch (request.method) {
|
|
||||||
case "POST":
|
|
||||||
this.ensureMethod(request, ["GET", "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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getRoot(route: Route, error?: Error): Promise<HttpResponse> {
|
|
||||||
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html")
|
|
||||||
response.content = response.content.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : "")
|
|
||||||
let passwordMsg = `Check the config file at ${humanPath(this.configFile)} for the password.`
|
|
||||||
if (this.envPassword) {
|
|
||||||
passwordMsg = "Password was set from $PASSWORD."
|
|
||||||
}
|
|
||||||
response.content = response.content.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
|
|
||||||
return this.replaceTemplates(route, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly limiter = new RateLimiter()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!this.limiter.try()) {
|
|
||||||
throw new Error("Login rate limited!")
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await this.getData(request)
|
|
||||||
const payload = data ? querystring.parse(data) : {}
|
|
||||||
return await this.login(payload, route, request)
|
|
||||||
} catch (error) {
|
|
||||||
return this.getRoot(route, 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RateLimiter wraps around the limiter library for logins.
|
|
||||||
// It allows 2 logins every minute and 12 logins every hour.
|
|
||||||
class RateLimiter {
|
|
||||||
private readonly minuteLimiter = new limiter.RateLimiter(2, "minute")
|
|
||||||
private readonly hourLimiter = new limiter.RateLimiter(12, "hour")
|
|
||||||
|
|
||||||
public try(): boolean {
|
|
||||||
if (this.minuteLimiter.tryRemoveTokens(1)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return this.hourLimiter.tryRemoveTokens(1)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,43 +0,0 @@
|
|||||||
import * as http from "http"
|
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
|
||||||
import { HttpProvider, HttpResponse, Route, WsResponse } from "../http"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxy HTTP provider.
|
|
||||||
*/
|
|
||||||
export class ProxyHttpProvider extends HttpProvider {
|
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
if (!this.authenticated(request)) {
|
|
||||||
if (this.isRoot(route)) {
|
|
||||||
return { redirect: "/login", query: { to: route.fullPath } }
|
|
||||||
}
|
|
||||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure there is a trailing slash so relative paths work correctly.
|
|
||||||
if (this.isRoot(route) && !route.fullPath.endsWith("/")) {
|
|
||||||
return {
|
|
||||||
redirect: `${route.fullPath}/`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const port = route.base.replace(/^\//, "")
|
|
||||||
return {
|
|
||||||
proxy: {
|
|
||||||
strip: `${route.providerBase}/${port}`,
|
|
||||||
port,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleWebSocket(route: Route, request: http.IncomingMessage): Promise<WsResponse> {
|
|
||||||
this.ensureAuthenticated(request)
|
|
||||||
const port = route.base.replace(/^\//, "")
|
|
||||||
return {
|
|
||||||
proxy: {
|
|
||||||
strip: `${route.providerBase}/${port}`,
|
|
||||||
port,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,73 +0,0 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
|
||||||
import * as http from "http"
|
|
||||||
import * as path from "path"
|
|
||||||
import { Readable } from "stream"
|
|
||||||
import * as tarFs from "tar-fs"
|
|
||||||
import * as zlib from "zlib"
|
|
||||||
import { HttpProvider, HttpResponse, Route } from "../http"
|
|
||||||
import { pathToFsPath } from "../util"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static file HTTP provider. Static requests do not require authentication if
|
|
||||||
* the resource is in the application's directory except requests to serve a
|
|
||||||
* directory as a tar which always requires authentication.
|
|
||||||
*/
|
|
||||||
export class StaticHttpProvider extends HttpProvider {
|
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
this.ensureMethod(request)
|
|
||||||
|
|
||||||
if (typeof route.query.tar === "string") {
|
|
||||||
this.ensureAuthenticated(request)
|
|
||||||
return this.getTarredResource(request, pathToFsPath(route.query.tar))
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.getReplacedResource(request, route)
|
|
||||||
if (!this.isDev) {
|
|
||||||
response.cache = true
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a resource with variables replaced where necessary.
|
|
||||||
*/
|
|
||||||
protected async getReplacedResource(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
|
|
||||||
// The first part is always the commit (for caching purposes).
|
|
||||||
const split = route.requestPath.split("/").slice(1)
|
|
||||||
|
|
||||||
const resourcePath = path.resolve("/", ...split)
|
|
||||||
|
|
||||||
// Make sure it's in code-server or a plugin.
|
|
||||||
const validPaths = [this.rootPath, process.env.PLUGIN_DIR]
|
|
||||||
if (!validPaths.find((p) => p && resourcePath.startsWith(p))) {
|
|
||||||
this.ensureAuthenticated(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (split[split.length - 1]) {
|
|
||||||
case "manifest.json": {
|
|
||||||
const response = await this.getUtf8Resource(resourcePath)
|
|
||||||
return this.replaceTemplates(route, response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.getResource(resourcePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tar up and stream a directory.
|
|
||||||
*/
|
|
||||||
private async getTarredResource(request: http.IncomingMessage, ...parts: string[]): Promise<HttpResponse> {
|
|
||||||
const filePath = path.join(...parts)
|
|
||||||
let stream: Readable = tarFs.pack(filePath)
|
|
||||||
const headers: http.OutgoingHttpHeaders = {}
|
|
||||||
if (request.headers["accept-encoding"] && request.headers["accept-encoding"].includes("gzip")) {
|
|
||||||
logger.debug("gzipping tar", field("filePath", filePath))
|
|
||||||
const compress = zlib.createGzip()
|
|
||||||
stream.pipe(compress)
|
|
||||||
stream.on("error", (error) => compress.destroy(error))
|
|
||||||
stream.on("close", () => compress.end())
|
|
||||||
stream = compress
|
|
||||||
headers["content-encoding"] = "gzip"
|
|
||||||
}
|
|
||||||
return { stream, filePath, mime: "application/x-tar", cache: true, headers }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,240 +0,0 @@
|
|||||||
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 {
|
|
||||||
CodeServerMessage,
|
|
||||||
Options,
|
|
||||||
StartPath,
|
|
||||||
VscodeMessage,
|
|
||||||
VscodeOptions,
|
|
||||||
WorkbenchOptions,
|
|
||||||
} from "../../../lib/vscode/src/vs/server/ipc"
|
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
|
||||||
import { arrayify, generateUuid } from "../../common/util"
|
|
||||||
import { Args } from "../cli"
|
|
||||||
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
|
||||||
import { settings } from "../settings"
|
|
||||||
import { pathToFsPath } from "../util"
|
|
||||||
|
|
||||||
export class VscodeHttpProvider extends HttpProvider {
|
|
||||||
private readonly serverRootPath: string
|
|
||||||
private readonly vsRootPath: string
|
|
||||||
private _vscode?: Promise<cp.ChildProcess>
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
public get running(): boolean {
|
|
||||||
return !!this._vscode
|
|
||||||
}
|
|
||||||
|
|
||||||
public async dispose(): Promise<void> {
|
|
||||||
if (this._vscode) {
|
|
||||||
const vscode = await this._vscode
|
|
||||||
vscode.removeAllListeners()
|
|
||||||
this._vscode = undefined
|
|
||||||
vscode.kill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
const onMessage = (message: VscodeMessage) => {
|
|
||||||
// There can be parallel initializations so wait for the right ID.
|
|
||||||
if (message.type === "options" && message.id === id) {
|
|
||||||
logger.trace("got message from vs code", field("message", message))
|
|
||||||
vscode.off("message", onMessage)
|
|
||||||
resolve(message.options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vscode.on("message", onMessage)
|
|
||||||
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.trace("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<void> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
|
||||||
this.ensureMethod(request)
|
|
||||||
|
|
||||||
switch (route.base) {
|
|
||||||
case "/":
|
|
||||||
if (!this.isRoot(route)) {
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
} else if (!this.authenticated(request)) {
|
|
||||||
return { redirect: "/login", query: { to: route.providerBase } }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await this.getRoot(request, route)
|
|
||||||
} catch (error) {
|
|
||||||
const message = `<div>VS Code failed to load.</div> ${
|
|
||||||
this.isDev
|
|
||||||
? `<div>It might not have finished compiling.</div>` +
|
|
||||||
`Check for <code>Finished <span class="success">compilation</span></code> in the output.`
|
|
||||||
: ""
|
|
||||||
} <br><br>${error}`
|
|
||||||
return this.getErrorRoot(route, "VS Code failed to load", "500", message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ensureAuthenticated(request)
|
|
||||||
|
|
||||||
switch (route.base) {
|
|
||||||
case "/resource":
|
|
||||||
case "/vscode-remote-resource":
|
|
||||||
if (typeof route.query.path === "string") {
|
|
||||||
return this.getResource(pathToFsPath(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getRoot(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
|
|
||||||
const remoteAuthority = request.headers.host as string
|
|
||||||
const { lastVisited } = await settings.read()
|
|
||||||
const startPath = await this.getFirstPath([
|
|
||||||
{ url: route.query.workspace, workspace: true },
|
|
||||||
{ url: route.query.folder, workspace: false },
|
|
||||||
this.args._ && this.args._.length > 0 ? { url: path.resolve(this.args._[this.args._.length - 1]) } : undefined,
|
|
||||||
lastVisited,
|
|
||||||
])
|
|
||||||
const [response, options] = await Promise.all([
|
|
||||||
await this.getUtf8Resource(this.rootPath, "src/browser/pages/vscode.html"),
|
|
||||||
this.initialize({
|
|
||||||
args: this.args,
|
|
||||||
remoteAuthority,
|
|
||||||
startPath,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
settings.write({
|
|
||||||
lastVisited: startPath || lastVisited, // If startpath is undefined, then fallback to lastVisited
|
|
||||||
query: route.query,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!this.isDev) {
|
|
||||||
response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
options.productConfiguration.codeServerVersion = require("../../../package.json").version
|
|
||||||
|
|
||||||
response.content = response.content
|
|
||||||
.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)}'`)
|
|
||||||
return this.replaceTemplates<Options>(route, response, {
|
|
||||||
disableTelemetry: !!this.args["disable-telemetry"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Choose the first non-empty path.
|
|
||||||
*/
|
|
||||||
private async getFirstPath(
|
|
||||||
startPaths: Array<{ url?: string | string[]; workspace?: boolean } | undefined>,
|
|
||||||
): Promise<StartPath | undefined> {
|
|
||||||
const isFile = async (path: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const stat = await fs.stat(path)
|
|
||||||
return stat.isFile()
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(error.message)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i = 0; i < startPaths.length; ++i) {
|
|
||||||
const startPath = startPaths[i]
|
|
||||||
const url = arrayify(startPath && startPath.url).find((p) => !!p)
|
|
||||||
if (startPath && url) {
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
// The only time `workspace` is undefined is for the command-line
|
|
||||||
// argument, in which case it's a path (not a URL) so we can stat it
|
|
||||||
// without having to parse it.
|
|
||||||
workspace: typeof startPath.workspace !== "undefined" ? startPath.workspace : await isFile(url),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
164
src/node/cli.ts
164
src/node/cli.ts
@ -4,8 +4,12 @@ import yaml from "js-yaml"
|
|||||||
import * as os from "os"
|
import * as os from "os"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { Args as VsArgs } from "../../lib/vscode/src/vs/server/ipc"
|
import { Args as VsArgs } from "../../lib/vscode/src/vs/server/ipc"
|
||||||
import { AuthType } from "./http"
|
import { canConnect, generateCertificate, generatePassword, humanPath, paths } from "./util"
|
||||||
import { canConnect, generatePassword, humanPath, paths } from "./util"
|
|
||||||
|
export enum AuthType {
|
||||||
|
Password = "password",
|
||||||
|
None = "none",
|
||||||
|
}
|
||||||
|
|
||||||
export class Optional<T> {
|
export class Optional<T> {
|
||||||
public constructor(public readonly value?: T) {}
|
public constructor(public readonly value?: T) {}
|
||||||
@ -22,34 +26,34 @@ export enum LogLevel {
|
|||||||
export class OptionalString extends Optional<string> {}
|
export class OptionalString extends Optional<string> {}
|
||||||
|
|
||||||
export interface Args extends VsArgs {
|
export interface Args extends VsArgs {
|
||||||
readonly config?: string
|
config?: string
|
||||||
readonly auth?: AuthType
|
auth?: AuthType
|
||||||
readonly password?: string
|
password?: string
|
||||||
readonly cert?: OptionalString
|
cert?: OptionalString
|
||||||
readonly "cert-host"?: string
|
"cert-host"?: string
|
||||||
readonly "cert-key"?: string
|
"cert-key"?: string
|
||||||
readonly "disable-telemetry"?: boolean
|
"disable-telemetry"?: boolean
|
||||||
readonly help?: boolean
|
help?: boolean
|
||||||
readonly host?: string
|
host?: string
|
||||||
readonly json?: boolean
|
json?: boolean
|
||||||
log?: LogLevel
|
log?: LogLevel
|
||||||
readonly open?: boolean
|
open?: boolean
|
||||||
readonly port?: number
|
port?: number
|
||||||
readonly "bind-addr"?: string
|
"bind-addr"?: string
|
||||||
readonly socket?: string
|
socket?: string
|
||||||
readonly version?: boolean
|
version?: boolean
|
||||||
readonly force?: boolean
|
force?: boolean
|
||||||
readonly "list-extensions"?: boolean
|
"list-extensions"?: boolean
|
||||||
readonly "install-extension"?: string[]
|
"install-extension"?: string[]
|
||||||
readonly "show-versions"?: boolean
|
"show-versions"?: boolean
|
||||||
readonly "uninstall-extension"?: string[]
|
"uninstall-extension"?: string[]
|
||||||
readonly "proxy-domain"?: string[]
|
"proxy-domain"?: string[]
|
||||||
readonly locale?: string
|
locale?: string
|
||||||
readonly _: string[]
|
_: string[]
|
||||||
readonly "reuse-window"?: boolean
|
"reuse-window"?: boolean
|
||||||
readonly "new-window"?: boolean
|
"new-window"?: boolean
|
||||||
|
|
||||||
readonly link?: OptionalString
|
link?: OptionalString
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Option<T> {
|
interface Option<T> {
|
||||||
@ -330,13 +334,37 @@ export const parse = (
|
|||||||
args._.push(arg)
|
args._.push(arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("parsed command line", field("args", args))
|
// If a cert was provided a key must also be provided.
|
||||||
|
if (args.cert && args.cert.value && !args["cert-key"]) {
|
||||||
|
throw new Error("--cert-key is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(() => ["parsed command line", field("args", { ...args, password: undefined })])
|
||||||
|
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setDefaults(args: Args): Promise<Args> {
|
export interface DefaultedArgs extends ConfigArgs {
|
||||||
args = { ...args }
|
auth: AuthType
|
||||||
|
cert?: {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
"proxy-domain": string[]
|
||||||
|
verbose: boolean
|
||||||
|
usingEnvPassword: boolean
|
||||||
|
"extensions-dir": string
|
||||||
|
"user-data-dir": string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take CLI and config arguments (optional) and return a single set of arguments
|
||||||
|
* with the defaults set. Arguments from the CLI are prioritized over config
|
||||||
|
* arguments.
|
||||||
|
*/
|
||||||
|
export async function setDefaults(cliArgs: Args, configArgs?: ConfigArgs): Promise<DefaultedArgs> {
|
||||||
|
const args = Object.assign({}, configArgs || {}, cliArgs)
|
||||||
|
|
||||||
if (!args["user-data-dir"]) {
|
if (!args["user-data-dir"]) {
|
||||||
await copyOldMacOSDataDir()
|
await copyOldMacOSDataDir()
|
||||||
@ -386,7 +414,49 @@ export async function setDefaults(args: Args): Promise<Args> {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return args
|
// Default to using a password.
|
||||||
|
if (!args.auth) {
|
||||||
|
args.auth = AuthType.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
const addr = bindAddrFromAllSources(configArgs || { _: [] }, cliArgs)
|
||||||
|
args.host = addr.host
|
||||||
|
args.port = addr.port
|
||||||
|
|
||||||
|
// If we're being exposed to the cloud, we listen on a random address and
|
||||||
|
// disable auth.
|
||||||
|
if (args.link) {
|
||||||
|
args.host = "localhost"
|
||||||
|
args.port = 0
|
||||||
|
args.socket = undefined
|
||||||
|
args.cert = undefined
|
||||||
|
args.auth = AuthType.None
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.cert && !args.cert.value) {
|
||||||
|
const { cert, certKey } = await generateCertificate(args["cert-host"] || "localhost")
|
||||||
|
args.cert = {
|
||||||
|
value: cert,
|
||||||
|
}
|
||||||
|
args["cert-key"] = certKey
|
||||||
|
}
|
||||||
|
|
||||||
|
const usingEnvPassword = !!process.env.PASSWORD
|
||||||
|
if (process.env.PASSWORD) {
|
||||||
|
args.password = process.env.PASSWORD
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's not readable by child processes.
|
||||||
|
delete process.env.PASSWORD
|
||||||
|
|
||||||
|
// Filter duplicate proxy domains and remove any leading `*.`.
|
||||||
|
const proxyDomains = new Set((args["proxy-domain"] || []).map((d) => d.replace(/^\*\./, "")))
|
||||||
|
args["proxy-domain"] = Array.from(proxyDomains)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...args,
|
||||||
|
usingEnvPassword,
|
||||||
|
} as DefaultedArgs // TODO: Technically no guarantee this is fulfilled.
|
||||||
}
|
}
|
||||||
|
|
||||||
async function defaultConfigFile(): Promise<string> {
|
async function defaultConfigFile(): Promise<string> {
|
||||||
@ -397,12 +467,16 @@ cert: false
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConfigArgs extends Args {
|
||||||
|
config: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the code-server yaml config file and returns it as Args.
|
* Reads the code-server yaml config file and returns it as Args.
|
||||||
*
|
*
|
||||||
* @param configPath Read the config from configPath instead of $CODE_SERVER_CONFIG or the default.
|
* @param configPath Read the config from configPath instead of $CODE_SERVER_CONFIG or the default.
|
||||||
*/
|
*/
|
||||||
export async function readConfigFile(configPath?: string): Promise<Args> {
|
export async function readConfigFile(configPath?: string): Promise<ConfigArgs> {
|
||||||
if (!configPath) {
|
if (!configPath) {
|
||||||
configPath = process.env.CODE_SERVER_CONFIG
|
configPath = process.env.CODE_SERVER_CONFIG
|
||||||
if (!configPath) {
|
if (!configPath) {
|
||||||
@ -440,12 +514,15 @@ export async function readConfigFile(configPath?: string): Promise<Args> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBindAddr(bindAddr: string): [string, number] {
|
function parseBindAddr(bindAddr: string): Addr {
|
||||||
const u = new URL(`http://${bindAddr}`)
|
const u = new URL(`http://${bindAddr}`)
|
||||||
// With the http scheme 80 will be dropped so assume it's 80 if missing. This
|
return {
|
||||||
// means --bind-addr <addr> without a port will default to 80 as well and not
|
host: u.hostname,
|
||||||
// the code-server default.
|
// With the http scheme 80 will be dropped so assume it's 80 if missing.
|
||||||
return [u.hostname, u.port ? parseInt(u.port, 10) : 80]
|
// This means --bind-addr <addr> without a port will default to 80 as well
|
||||||
|
// and not the code-server default.
|
||||||
|
port: u.port ? parseInt(u.port, 10) : 80,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Addr {
|
interface Addr {
|
||||||
@ -456,7 +533,7 @@ interface Addr {
|
|||||||
function bindAddrFromArgs(addr: Addr, args: Args): Addr {
|
function bindAddrFromArgs(addr: Addr, args: Args): Addr {
|
||||||
addr = { ...addr }
|
addr = { ...addr }
|
||||||
if (args["bind-addr"]) {
|
if (args["bind-addr"]) {
|
||||||
;[addr.host, addr.port] = parseBindAddr(args["bind-addr"])
|
addr = parseBindAddr(args["bind-addr"])
|
||||||
}
|
}
|
||||||
if (args.host) {
|
if (args.host) {
|
||||||
addr.host = args.host
|
addr.host = args.host
|
||||||
@ -471,16 +548,17 @@ function bindAddrFromArgs(addr: Addr, args: Args): Addr {
|
|||||||
return addr
|
return addr
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bindAddrFromAllSources(cliArgs: Args, configArgs: Args): [string, number] {
|
function bindAddrFromAllSources(...argsConfig: Args[]): Addr {
|
||||||
let addr: Addr = {
|
let addr: Addr = {
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
}
|
}
|
||||||
|
|
||||||
addr = bindAddrFromArgs(addr, configArgs)
|
for (const args of argsConfig) {
|
||||||
addr = bindAddrFromArgs(addr, cliArgs)
|
addr = bindAddrFromArgs(addr, args)
|
||||||
|
}
|
||||||
|
|
||||||
return [addr.host, addr.port]
|
return addr
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyOldMacOSDataDir(): Promise<void> {
|
async function copyOldMacOSDataDir(): Promise<void> {
|
||||||
|
13
src/node/constants.ts
Normal file
13
src/node/constants.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { logger } from "@coder/logger"
|
||||||
|
import * as path from "path"
|
||||||
|
|
||||||
|
let pkg: { version?: string; commit?: string } = {}
|
||||||
|
try {
|
||||||
|
pkg = require("../../package.json")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const version = pkg.version || "development"
|
||||||
|
export const commit = pkg.commit || "development"
|
||||||
|
export const rootPath = path.resolve(__dirname, "../..")
|
@ -1,19 +1,13 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
import { field, logger } from "@coder/logger"
|
||||||
import * as cp from "child_process"
|
import * as cp from "child_process"
|
||||||
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 { CliMessage, OpenCommandPipeArgs } from "../../lib/vscode/src/vs/server/ipc"
|
import { CliMessage, OpenCommandPipeArgs } from "../../lib/vscode/src/vs/server/ipc"
|
||||||
import { plural } from "../common/util"
|
import { plural } from "../common/util"
|
||||||
import { HealthHttpProvider } from "./app/health"
|
import { createApp, ensureAddress } from "./app"
|
||||||
import { LoginHttpProvider } from "./app/login"
|
|
||||||
import { ProxyHttpProvider } from "./app/proxy"
|
|
||||||
import { StaticHttpProvider } from "./app/static"
|
|
||||||
import { UpdateHttpProvider } from "./app/update"
|
|
||||||
import { VscodeHttpProvider } from "./app/vscode"
|
|
||||||
import {
|
import {
|
||||||
Args,
|
AuthType,
|
||||||
bindAddrFromAllSources,
|
DefaultedArgs,
|
||||||
optionDescriptions,
|
optionDescriptions,
|
||||||
parse,
|
parse,
|
||||||
readConfigFile,
|
readConfigFile,
|
||||||
@ -22,22 +16,12 @@ import {
|
|||||||
shouldRunVsCodeCli,
|
shouldRunVsCodeCli,
|
||||||
} from "./cli"
|
} from "./cli"
|
||||||
import { coderCloudBind } from "./coder-cloud"
|
import { coderCloudBind } from "./coder-cloud"
|
||||||
import { AuthType, HttpServer, HttpServerOptions } from "./http"
|
import { commit, version } from "./constants"
|
||||||
import { loadPlugins } from "./plugin"
|
import { register } from "./routes"
|
||||||
import { generateCertificate, hash, humanPath, open } from "./util"
|
import { humanPath, isFile, open } from "./util"
|
||||||
import { ipcMain, WrapperProcess } from "./wrapper"
|
import { ipcMain, WrapperProcess } from "./wrapper"
|
||||||
|
|
||||||
let pkg: { version?: string; commit?: string } = {}
|
export const runVsCodeCli = (args: DefaultedArgs): void => {
|
||||||
try {
|
|
||||||
pkg = require("../../package.json")
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = pkg.version || "development"
|
|
||||||
const commit = pkg.commit || "development"
|
|
||||||
|
|
||||||
export const runVsCodeCli = (args: Args): void => {
|
|
||||||
logger.debug("forking vs code cli...")
|
logger.debug("forking vs code cli...")
|
||||||
const vscode = cp.fork(path.resolve(__dirname, "../../lib/vscode/out/vs/server/fork"), [], {
|
const vscode = cp.fork(path.resolve(__dirname, "../../lib/vscode/out/vs/server/fork"), [], {
|
||||||
env: {
|
env: {
|
||||||
@ -61,7 +45,7 @@ export const runVsCodeCli = (args: Args): void => {
|
|||||||
vscode.on("exit", (code) => process.exit(code || 0))
|
vscode.on("exit", (code) => process.exit(code || 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const openInExistingInstance = async (args: Args, socketPath: string): Promise<void> => {
|
export const openInExistingInstance = async (args: DefaultedArgs, socketPath: string): Promise<void> => {
|
||||||
const pipeArgs: OpenCommandPipeArgs & { fileURIs: string[] } = {
|
const pipeArgs: OpenCommandPipeArgs & { fileURIs: string[] } = {
|
||||||
type: "open",
|
type: "open",
|
||||||
folderURIs: [],
|
folderURIs: [],
|
||||||
@ -70,21 +54,12 @@ export const openInExistingInstance = async (args: Args, socketPath: string): Pr
|
|||||||
forceNewWindow: args["new-window"],
|
forceNewWindow: args["new-window"],
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDir = async (path: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const st = await fs.stat(path)
|
|
||||||
return st.isDirectory()
|
|
||||||
} catch (error) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < args._.length; i++) {
|
for (let i = 0; i < args._.length; i++) {
|
||||||
const fp = path.resolve(args._[i])
|
const fp = path.resolve(args._[i])
|
||||||
if (await isDir(fp)) {
|
if (await isFile(fp)) {
|
||||||
pipeArgs.folderURIs.push(fp)
|
|
||||||
} else {
|
|
||||||
pipeArgs.fileURIs.push(fp)
|
pipeArgs.fileURIs.push(fp)
|
||||||
|
} else {
|
||||||
|
pipeArgs.folderURIs.push(fp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,134 +92,71 @@ export const openInExistingInstance = async (args: Args, socketPath: string): Pr
|
|||||||
vscode.end()
|
vscode.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
const main = async (args: Args, configArgs: Args): Promise<void> => {
|
const main = async (args: DefaultedArgs): Promise<void> => {
|
||||||
if (args.link) {
|
logger.info(`code-server ${version} ${commit}`)
|
||||||
// If we're being exposed to the cloud, we listen on a random address and disable auth.
|
|
||||||
args = {
|
|
||||||
...args,
|
|
||||||
host: "localhost",
|
|
||||||
port: 0,
|
|
||||||
auth: AuthType.None,
|
|
||||||
socket: undefined,
|
|
||||||
cert: undefined,
|
|
||||||
}
|
|
||||||
logger.info("link: disabling auth and listening on random localhost port for cloud agent")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.auth) {
|
|
||||||
args = {
|
|
||||||
...args,
|
|
||||||
auth: AuthType.Password,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Using user-data-dir ${humanPath(args["user-data-dir"])}`)
|
logger.info(`Using user-data-dir ${humanPath(args["user-data-dir"])}`)
|
||||||
|
|
||||||
logger.trace(`Using extensions-dir ${humanPath(args["extensions-dir"])}`)
|
logger.trace(`Using extensions-dir ${humanPath(args["extensions-dir"])}`)
|
||||||
|
|
||||||
const envPassword = !!process.env.PASSWORD
|
if (args.auth === AuthType.Password && !args.password) {
|
||||||
const password = args.auth === AuthType.Password && (process.env.PASSWORD || args.password)
|
|
||||||
if (args.auth === AuthType.Password && !password) {
|
|
||||||
throw new Error("Please pass in a password via the config file or $PASSWORD")
|
throw new Error("Please pass in a password via the config file or $PASSWORD")
|
||||||
}
|
}
|
||||||
const [host, port] = bindAddrFromAllSources(args, configArgs)
|
|
||||||
|
|
||||||
// Spawn the main HTTP server.
|
const [app, wsApp, server] = await createApp(args)
|
||||||
const options: HttpServerOptions = {
|
const serverAddress = ensureAddress(server)
|
||||||
auth: args.auth,
|
await register(app, wsApp, server, args)
|
||||||
commit,
|
|
||||||
host: host,
|
|
||||||
// The hash does not add any actual security but we do it for obfuscation purposes.
|
|
||||||
password: password ? hash(password) : undefined,
|
|
||||||
port: port,
|
|
||||||
proxyDomains: args["proxy-domain"],
|
|
||||||
socket: args.socket,
|
|
||||||
...(args.cert && !args.cert.value
|
|
||||||
? await generateCertificate(args["cert-host"] || "localhost")
|
|
||||||
: {
|
|
||||||
cert: args.cert && args.cert.value,
|
|
||||||
certKey: args["cert-key"],
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.cert && !options.certKey) {
|
|
||||||
throw new Error("--cert-key is missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpServer = new HttpServer(options)
|
|
||||||
httpServer.registerHttpProvider(["/", "/vscode"], VscodeHttpProvider, args)
|
|
||||||
httpServer.registerHttpProvider("/update", UpdateHttpProvider, false)
|
|
||||||
httpServer.registerHttpProvider("/proxy", ProxyHttpProvider)
|
|
||||||
httpServer.registerHttpProvider("/login", LoginHttpProvider, args.config!, envPassword)
|
|
||||||
httpServer.registerHttpProvider("/static", StaticHttpProvider)
|
|
||||||
httpServer.registerHttpProvider("/healthz", HealthHttpProvider, httpServer.heart)
|
|
||||||
|
|
||||||
await loadPlugins(httpServer, args)
|
|
||||||
|
|
||||||
ipcMain.onDispose(() => {
|
|
||||||
httpServer.dispose().then((errors) => {
|
|
||||||
errors.forEach((error) => logger.error(error.message))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(`code-server ${version} ${commit}`)
|
|
||||||
logger.info(`Using config file ${humanPath(args.config)}`)
|
logger.info(`Using config file ${humanPath(args.config)}`)
|
||||||
|
logger.info(`HTTP server listening on ${serverAddress} ${args.link ? "(randomized by --link)" : ""}`)
|
||||||
const serverAddress = await httpServer.listen()
|
|
||||||
logger.info(`HTTP server listening on ${serverAddress}`)
|
|
||||||
|
|
||||||
if (args.auth === AuthType.Password) {
|
if (args.auth === AuthType.Password) {
|
||||||
if (envPassword) {
|
logger.info(" - Authentication is enabled")
|
||||||
|
if (args.usingEnvPassword) {
|
||||||
logger.info(" - Using password from $PASSWORD")
|
logger.info(" - Using password from $PASSWORD")
|
||||||
} else {
|
} else {
|
||||||
logger.info(` - Using password from ${humanPath(args.config)}`)
|
logger.info(` - Using password from ${humanPath(args.config)}`)
|
||||||
}
|
}
|
||||||
logger.info(" - To disable use `--auth none`")
|
|
||||||
} else {
|
} else {
|
||||||
logger.info(" - No authentication")
|
logger.info(` - Authentication is disabled ${args.link ? "(disabled by --link)" : ""}`)
|
||||||
}
|
}
|
||||||
delete process.env.PASSWORD
|
|
||||||
|
|
||||||
if (httpServer.protocol === "https") {
|
if (args.cert) {
|
||||||
logger.info(
|
logger.info(" - Using certificate for HTTPS: ${humanPath(args.cert.value)}")
|
||||||
args.cert && args.cert.value
|
|
||||||
? ` - Using provided certificate and key for HTTPS`
|
|
||||||
: ` - Using generated certificate and key for HTTPS: ${humanPath(options.cert)}`,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
logger.info(" - Not serving HTTPS")
|
logger.info(" - Not serving HTTPS")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (httpServer.proxyDomains.size > 0) {
|
if (args["proxy-domain"].length > 0) {
|
||||||
logger.info(` - ${plural(httpServer.proxyDomains.size, "Proxying the following domain")}:`)
|
logger.info(` - ${plural(args["proxy-domain"].length, "Proxying the following domain")}:`)
|
||||||
httpServer.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`))
|
args["proxy-domain"].forEach((domain) => logger.info(` - *.${domain}`))
|
||||||
}
|
|
||||||
|
|
||||||
if (serverAddress && !options.socket && args.open) {
|
|
||||||
// The web socket doesn't seem to work if browsing with 0.0.0.0.
|
|
||||||
const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost")
|
|
||||||
await open(openAddress).catch((error: Error) => {
|
|
||||||
logger.error("Failed to open", field("address", openAddress), field("error", error))
|
|
||||||
})
|
|
||||||
logger.info(`Opened ${openAddress}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.link) {
|
if (args.link) {
|
||||||
try {
|
try {
|
||||||
await coderCloudBind(serverAddress!, args.link.value)
|
await coderCloudBind(serverAddress.replace(/^https?:\/\//, ""), args.link.value)
|
||||||
|
logger.info(" - Connected to cloud agent")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err.message)
|
logger.error(err.message)
|
||||||
ipcMain.exit(1)
|
ipcMain.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!args.socket && args.open) {
|
||||||
|
// The web socket doesn't seem to work if browsing with 0.0.0.0.
|
||||||
|
const openAddress = serverAddress.replace("://0.0.0.0", "://localhost")
|
||||||
|
try {
|
||||||
|
await open(openAddress)
|
||||||
|
logger.info(`Opened ${openAddress}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to open", field("address", openAddress), field("error", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function entry(): Promise<void> {
|
async function entry(): Promise<void> {
|
||||||
const cliArgs = parse(process.argv.slice(2))
|
const cliArgs = parse(process.argv.slice(2))
|
||||||
const configArgs = await readConfigFile(cliArgs.config)
|
const configArgs = await readConfigFile(cliArgs.config)
|
||||||
// This prioritizes the flags set in args over the ones in the config file.
|
const args = await setDefaults(cliArgs, configArgs)
|
||||||
let args = Object.assign(configArgs, cliArgs)
|
|
||||||
args = await setDefaults(args)
|
|
||||||
|
|
||||||
// There's no need to check flags like --help or to spawn in an existing
|
// There's no need to check flags like --help or to spawn in an existing
|
||||||
// instance for the child process because these would have already happened in
|
// instance for the child process because these would have already happened in
|
||||||
@ -252,7 +164,7 @@ async function entry(): Promise<void> {
|
|||||||
if (ipcMain.isChild) {
|
if (ipcMain.isChild) {
|
||||||
await ipcMain.handshake()
|
await ipcMain.handshake()
|
||||||
ipcMain.preventExit()
|
ipcMain.preventExit()
|
||||||
return main(args, configArgs)
|
return main(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.help) {
|
if (args.help) {
|
||||||
|
48
src/node/heart.ts
Normal file
48
src/node/heart.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { logger } from "@coder/logger"
|
||||||
|
import { promises as fs } from "fs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a heartbeat using a local file to indicate activity.
|
||||||
|
*/
|
||||||
|
export class Heart {
|
||||||
|
private heartbeatTimer?: NodeJS.Timeout
|
||||||
|
private heartbeatInterval = 60000
|
||||||
|
public lastHeartbeat = 0
|
||||||
|
|
||||||
|
public constructor(private readonly heartbeatPath: string, private readonly isActive: () => Promise<boolean>) {}
|
||||||
|
|
||||||
|
public alive(): boolean {
|
||||||
|
const now = Date.now()
|
||||||
|
return now - this.lastHeartbeat < this.heartbeatInterval
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Write to the heartbeat file if we haven't already done so within the
|
||||||
|
* timeout and start or reset a timer that keeps running as long as there is
|
||||||
|
* activity. Failures are logged as warnings.
|
||||||
|
*/
|
||||||
|
public beat(): void {
|
||||||
|
if (this.alive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.trace("heartbeat")
|
||||||
|
fs.writeFile(this.heartbeatPath, "").catch((error) => {
|
||||||
|
logger.warn(error.message)
|
||||||
|
})
|
||||||
|
this.lastHeartbeat = Date.now()
|
||||||
|
if (typeof this.heartbeatTimer !== "undefined") {
|
||||||
|
clearTimeout(this.heartbeatTimer)
|
||||||
|
}
|
||||||
|
this.heartbeatTimer = setTimeout(() => {
|
||||||
|
this.isActive()
|
||||||
|
.then((active) => {
|
||||||
|
if (active) {
|
||||||
|
this.beat()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn(error.message)
|
||||||
|
})
|
||||||
|
}, this.heartbeatInterval)
|
||||||
|
}
|
||||||
|
}
|
966
src/node/http.ts
966
src/node/http.ts
File diff suppressed because it is too large
Load Diff
@ -1,92 +1,249 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
import { Logger, field } from "@coder/logger"
|
||||||
|
import * as express from "express"
|
||||||
import * as fs from "fs"
|
import * as fs from "fs"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import * as util from "util"
|
import * as semver from "semver"
|
||||||
import { Args } from "./cli"
|
import * as pluginapi from "../../typings/pluginapi"
|
||||||
import { HttpServer } from "./http"
|
import { version } from "./constants"
|
||||||
import { paths } from "./util"
|
import * as util from "./util"
|
||||||
|
const fsp = fs.promises
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
interface Plugin extends pluginapi.Plugin {
|
||||||
|
/**
|
||||||
export type Activate = (httpServer: HttpServer, args: Args) => void
|
* These fields are populated from the plugin's package.json
|
||||||
|
* and now guaranteed to exist.
|
||||||
|
*/
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugins must implement this interface.
|
* path to the node module on the disk.
|
||||||
*/
|
*/
|
||||||
export interface Plugin {
|
modulePath: string
|
||||||
activate: Activate
|
}
|
||||||
|
|
||||||
|
interface Application extends pluginapi.Application {
|
||||||
|
/*
|
||||||
|
* Clone of the above without functions.
|
||||||
|
*/
|
||||||
|
plugin: Omit<Plugin, "init" | "router" | "applications">
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intercept imports so we can inject code-server when the plugin tries to
|
* PluginAPI implements the plugin API described in typings/pluginapi.d.ts
|
||||||
* import it.
|
* Please see that file for details.
|
||||||
*/
|
*/
|
||||||
const originalLoad = require("module")._load
|
export class PluginAPI {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
private readonly plugins = new Map<string, Plugin>()
|
||||||
require("module")._load = function (request: string, parent: object, isMain: boolean): any {
|
private readonly logger: Logger
|
||||||
return originalLoad.apply(this, [request.replace(/^code-server/, path.resolve(__dirname, "../..")), parent, isMain])
|
|
||||||
|
public constructor(
|
||||||
|
logger: Logger,
|
||||||
|
/**
|
||||||
|
* These correspond to $CS_PLUGIN_PATH and $CS_PLUGIN respectively.
|
||||||
|
*/
|
||||||
|
private readonly csPlugin = "",
|
||||||
|
private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`,
|
||||||
|
) {
|
||||||
|
this.logger = logger.named("pluginapi")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a plugin and run its activation function.
|
* applications grabs the full list of applications from
|
||||||
|
* all loaded plugins.
|
||||||
*/
|
*/
|
||||||
const loadPlugin = async (pluginPath: string, httpServer: HttpServer, args: Args): Promise<void> => {
|
public async applications(): Promise<Application[]> {
|
||||||
try {
|
const apps = new Array<Application>()
|
||||||
const plugin: Plugin = require(pluginPath)
|
for (const [, p] of this.plugins) {
|
||||||
plugin.activate(httpServer, args)
|
if (!p.applications) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const pluginApps = await p.applications()
|
||||||
|
|
||||||
const packageJson = require(path.join(pluginPath, "package.json"))
|
// Add plugin key to each app.
|
||||||
logger.debug(
|
apps.push(
|
||||||
"Loaded plugin",
|
...pluginApps.map((app) => {
|
||||||
field("name", packageJson.name || path.basename(pluginPath)),
|
app = { ...app, path: path.join(p.routerPath, app.path || "") }
|
||||||
field("path", pluginPath),
|
app = { ...app, iconPath: path.join(app.path || "", app.iconPath) }
|
||||||
field("version", packageJson.version || "n/a"),
|
return {
|
||||||
|
...app,
|
||||||
|
plugin: {
|
||||||
|
name: p.name,
|
||||||
|
version: p.version,
|
||||||
|
modulePath: p.modulePath,
|
||||||
|
|
||||||
|
displayName: p.displayName,
|
||||||
|
description: p.description,
|
||||||
|
routerPath: p.routerPath,
|
||||||
|
homepageURL: p.homepageURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
} catch (error) {
|
}
|
||||||
logger.error(error.message)
|
return apps
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mount mounts all plugin routers onto r.
|
||||||
|
*/
|
||||||
|
public mount(r: express.Router): void {
|
||||||
|
for (const [, p] of this.plugins) {
|
||||||
|
if (!p.router) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.use(`${p.routerPath}`, p.router())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all plugins in the specified directory.
|
* loadPlugins loads all plugins based on this.csPlugin,
|
||||||
|
* this.csPluginPath and the built in plugins.
|
||||||
*/
|
*/
|
||||||
const _loadPlugins = async (pluginDir: string, httpServer: HttpServer, args: Args): Promise<void> => {
|
public async loadPlugins(): Promise<void> {
|
||||||
try {
|
for (const dir of this.csPlugin.split(":")) {
|
||||||
const files = await util.promisify(fs.readdir)(pluginDir, {
|
if (!dir) {
|
||||||
withFileTypes: true,
|
continue
|
||||||
})
|
|
||||||
await Promise.all(files.map((file) => loadPlugin(path.join(pluginDir, file.name), httpServer, args)))
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== "ENOENT") {
|
|
||||||
logger.warn(error.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await this.loadPlugin(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of this.csPluginPath.split(":")) {
|
||||||
|
if (!dir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await this._loadPlugins(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all plugins from the `plugins` directory, directories specified by
|
|
||||||
* `CS_PLUGIN_PATH` (colon-separated), and individual plugins specified by
|
|
||||||
* `CS_PLUGIN` (also colon-separated).
|
|
||||||
*/
|
|
||||||
export const loadPlugins = async (httpServer: HttpServer, args: Args): Promise<void> => {
|
|
||||||
const pluginPath = process.env.CS_PLUGIN_PATH || `${path.join(paths.data, "plugins")}:/usr/share/code-server/plugins`
|
|
||||||
const plugin = process.env.CS_PLUGIN || ""
|
|
||||||
await Promise.all([
|
|
||||||
// Built-in plugins.
|
// Built-in plugins.
|
||||||
_loadPlugins(path.resolve(__dirname, "../../plugins"), httpServer, args),
|
await this._loadPlugins(path.join(__dirname, "../../plugins"))
|
||||||
// User-added plugins.
|
}
|
||||||
...pluginPath
|
|
||||||
.split(":")
|
/**
|
||||||
.filter((p) => !!p)
|
* _loadPlugins is the counterpart to loadPlugins.
|
||||||
.map((dir) => _loadPlugins(path.resolve(dir), httpServer, args)),
|
*
|
||||||
// Individual plugins so you don't have to symlink or move them into a
|
* It differs in that it loads all plugins in a single
|
||||||
// directory specifically for plugins. This lets you load plugins that are
|
* directory whereas loadPlugins uses all available directories
|
||||||
// on the same level as other directories that are not plugins (if you tried
|
* as documented.
|
||||||
// to use CS_PLUGIN_PATH code-server would try to load those other
|
*/
|
||||||
// directories as plugins). Intended for development.
|
private async _loadPlugins(dir: string): Promise<void> {
|
||||||
...plugin
|
try {
|
||||||
.split(":")
|
const entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||||
.filter((p) => !!p)
|
for (const ent of entries) {
|
||||||
.map((dir) => loadPlugin(path.resolve(dir), httpServer, args)),
|
if (!ent.isDirectory()) {
|
||||||
])
|
continue
|
||||||
|
}
|
||||||
|
await this.loadPlugin(path.join(dir, ent.name))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== "ENOENT") {
|
||||||
|
this.logger.warn(`failed to load plugins from ${q(dir)}: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPlugin(dir: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const str = await fsp.readFile(path.join(dir, "package.json"), {
|
||||||
|
encoding: "utf8",
|
||||||
|
})
|
||||||
|
const packageJSON: PackageJSON = JSON.parse(str)
|
||||||
|
for (const [, p] of this.plugins) {
|
||||||
|
if (p.name === packageJSON.name) {
|
||||||
|
this.logger.warn(
|
||||||
|
`ignoring duplicate plugin ${q(p.name)} at ${q(dir)}, using previously loaded ${q(p.modulePath)}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const p = this._loadPlugin(dir, packageJSON)
|
||||||
|
this.plugins.set(p.name, p)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== "ENOENT") {
|
||||||
|
this.logger.warn(`failed to load plugin: ${err.stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* _loadPlugin is the counterpart to loadPlugin and actually
|
||||||
|
* loads the plugin now that we know there is no duplicate
|
||||||
|
* and that the package.json has been read.
|
||||||
|
*/
|
||||||
|
private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin {
|
||||||
|
dir = path.resolve(dir)
|
||||||
|
|
||||||
|
const logger = this.logger.named(packageJSON.name)
|
||||||
|
logger.debug("loading plugin", field("plugin_dir", dir), field("package_json", packageJSON))
|
||||||
|
|
||||||
|
if (!packageJSON.name) {
|
||||||
|
throw new Error("plugin package.json missing name")
|
||||||
|
}
|
||||||
|
if (!packageJSON.version) {
|
||||||
|
throw new Error("plugin package.json missing version")
|
||||||
|
}
|
||||||
|
if (!packageJSON.engines || !packageJSON.engines["code-server"]) {
|
||||||
|
throw new Error(`plugin package.json missing code-server range like:
|
||||||
|
"engines": {
|
||||||
|
"code-server": "^3.6.0"
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
if (!semver.satisfies(version, packageJSON.engines["code-server"])) {
|
||||||
|
throw new Error(
|
||||||
|
`plugin range ${q(packageJSON.engines["code-server"])} incompatible` + ` with code-server version ${version}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginModule = require(dir)
|
||||||
|
if (!pluginModule.plugin) {
|
||||||
|
throw new Error("plugin module does not export a plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = {
|
||||||
|
name: packageJSON.name,
|
||||||
|
version: packageJSON.version,
|
||||||
|
modulePath: dir,
|
||||||
|
...pluginModule.plugin,
|
||||||
|
} as Plugin
|
||||||
|
|
||||||
|
if (!p.displayName) {
|
||||||
|
throw new Error("plugin missing displayName")
|
||||||
|
}
|
||||||
|
if (!p.description) {
|
||||||
|
throw new Error("plugin missing description")
|
||||||
|
}
|
||||||
|
if (!p.routerPath) {
|
||||||
|
throw new Error("plugin missing router path")
|
||||||
|
}
|
||||||
|
if (!p.routerPath.startsWith("/") || p.routerPath.length < 2) {
|
||||||
|
throw new Error(`plugin router path ${q(p.routerPath)}: invalid`)
|
||||||
|
}
|
||||||
|
if (!p.homepageURL) {
|
||||||
|
throw new Error("plugin missing homepage")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.init({
|
||||||
|
logger: logger,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug("loaded")
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageJSON {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
engines: {
|
||||||
|
"code-server": string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function q(s: string | undefined): string {
|
||||||
|
if (s === undefined) {
|
||||||
|
s = "undefined"
|
||||||
|
}
|
||||||
|
return JSON.stringify(s)
|
||||||
}
|
}
|
||||||
|
16
src/node/proxy.ts
Normal file
16
src/node/proxy.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import proxyServer from "http-proxy"
|
||||||
|
import { HttpCode } from "../common/http"
|
||||||
|
|
||||||
|
export const proxy = proxyServer.createProxyServer({})
|
||||||
|
|
||||||
|
proxy.on("error", (error, _, res) => {
|
||||||
|
res.writeHead(HttpCode.ServerError)
|
||||||
|
res.end(error.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Intercept the response to rewrite absolute redirects against the base path.
|
||||||
|
proxy.on("proxyRes", (res, req) => {
|
||||||
|
if (res.headers.location && res.headers.location.startsWith("/") && (req as any).base) {
|
||||||
|
res.headers.location = (req as any).base + res.headers.location
|
||||||
|
}
|
||||||
|
})
|
17
src/node/routes/apps.ts
Normal file
17
src/node/routes/apps.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import * as express from "express"
|
||||||
|
import { PluginAPI } from "../plugin"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the /api/applications endpoint
|
||||||
|
*
|
||||||
|
* See typings/pluginapi.d.ts for details.
|
||||||
|
*/
|
||||||
|
export function router(papi: PluginAPI): express.Router {
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get("/", async (req, res) => {
|
||||||
|
res.json(await papi.applications())
|
||||||
|
})
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
89
src/node/routes/domainProxy.ts
Normal file
89
src/node/routes/domainProxy.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { Request, Router } from "express"
|
||||||
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
|
import { normalize } from "../../common/util"
|
||||||
|
import { authenticated, ensureAuthenticated, redirect } from "../http"
|
||||||
|
import { proxy } from "../proxy"
|
||||||
|
import { Router as WsRouter } from "../wsRouter"
|
||||||
|
|
||||||
|
export const router = Router()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the port if the request should be proxied. Anything that ends in a
|
||||||
|
* proxy domain and has a *single* subdomain should be proxied. Anything else
|
||||||
|
* should return `undefined` and will be handled as normal.
|
||||||
|
*
|
||||||
|
* For example if `coder.com` is specified `8080.coder.com` will be proxied
|
||||||
|
* but `8080.test.coder.com` and `test.8080.coder.com` will not.
|
||||||
|
*/
|
||||||
|
const maybeProxy = (req: Request): string | undefined => {
|
||||||
|
// Split into parts.
|
||||||
|
const host = req.headers.host || ""
|
||||||
|
const idx = host.indexOf(":")
|
||||||
|
const domain = idx !== -1 ? host.substring(0, idx) : host
|
||||||
|
const parts = domain.split(".")
|
||||||
|
|
||||||
|
// There must be an exact match.
|
||||||
|
const port = parts.shift()
|
||||||
|
const proxyDomain = parts.join(".")
|
||||||
|
if (!port || !req.args["proxy-domain"].includes(proxyDomain)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
router.all("*", (req, res, next) => {
|
||||||
|
const port = maybeProxy(req)
|
||||||
|
if (!port) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be authenticated to use the proxy.
|
||||||
|
if (!authenticated(req)) {
|
||||||
|
// Let the assets through since they're used on the login page.
|
||||||
|
if (req.path.startsWith("/static/") && req.method === "GET") {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
if (typeof req.headers.accepts === "string" && req.headers.accepts.split(",").includes("text/html")) {
|
||||||
|
// Let the login through.
|
||||||
|
if (/\/login\/?/.test(req.path)) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
// Redirect all other pages to the login.
|
||||||
|
const to = normalize(`${req.baseUrl}${req.path}`)
|
||||||
|
return redirect(req, res, "login", {
|
||||||
|
to: to !== "/" ? to : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else gets an unauthorized message.
|
||||||
|
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.web(req, res, {
|
||||||
|
ignorePath: true,
|
||||||
|
target: `http://0.0.0.0:${port}${req.originalUrl}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const wsRouter = WsRouter()
|
||||||
|
|
||||||
|
wsRouter.ws("*", (req, _, next) => {
|
||||||
|
const port = maybeProxy(req)
|
||||||
|
if (!port) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be authenticated to use the proxy.
|
||||||
|
ensureAuthenticated(req)
|
||||||
|
|
||||||
|
proxy.ws(req, req.ws, req.head, {
|
||||||
|
ignorePath: true,
|
||||||
|
target: `http://0.0.0.0:${port}${req.originalUrl}`,
|
||||||
|
})
|
||||||
|
})
|
10
src/node/routes/health.ts
Normal file
10
src/node/routes/health.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Router } from "express"
|
||||||
|
|
||||||
|
export const router = Router()
|
||||||
|
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: req.heart.alive() ? "alive" : "expired",
|
||||||
|
lastHeartbeat: req.heart.lastHeartbeat,
|
||||||
|
})
|
||||||
|
})
|
162
src/node/routes/index.ts
Normal file
162
src/node/routes/index.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
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"
|
||||||
|
import http from "http"
|
||||||
|
import * as path from "path"
|
||||||
|
import * as tls from "tls"
|
||||||
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
|
import { plural } from "../../common/util"
|
||||||
|
import { AuthType, DefaultedArgs } from "../cli"
|
||||||
|
import { rootPath } from "../constants"
|
||||||
|
import { Heart } from "../heart"
|
||||||
|
import { replaceTemplates } from "../http"
|
||||||
|
import { PluginAPI } from "../plugin"
|
||||||
|
import { getMediaMime, paths } from "../util"
|
||||||
|
import { WebsocketRequest } from "../wsRouter"
|
||||||
|
import * as apps from "./apps"
|
||||||
|
import * as domainProxy from "./domainProxy"
|
||||||
|
import * as health from "./health"
|
||||||
|
import * as login from "./login"
|
||||||
|
import * as proxy from "./pathProxy"
|
||||||
|
// static is a reserved keyword.
|
||||||
|
import * as _static from "./static"
|
||||||
|
import * as update from "./update"
|
||||||
|
import * as vscode from "./vscode"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
namespace Express {
|
||||||
|
export interface Request {
|
||||||
|
args: DefaultedArgs
|
||||||
|
heart: Heart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all routes and middleware.
|
||||||
|
*/
|
||||||
|
export const register = async (
|
||||||
|
app: express.Express,
|
||||||
|
wsApp: express.Express,
|
||||||
|
server: http.Server,
|
||||||
|
args: DefaultedArgs,
|
||||||
|
): Promise<void> => {
|
||||||
|
const heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.getConnections((error, count) => {
|
||||||
|
if (error) {
|
||||||
|
return reject(error)
|
||||||
|
}
|
||||||
|
logger.trace(plural(count, `${count} active connection`))
|
||||||
|
resolve(count > 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.disable("x-powered-by")
|
||||||
|
wsApp.disable("x-powered-by")
|
||||||
|
|
||||||
|
app.use(cookieParser())
|
||||||
|
wsApp.use(cookieParser())
|
||||||
|
|
||||||
|
app.use(bodyParser.json())
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true }))
|
||||||
|
|
||||||
|
const common: express.RequestHandler = (req, _, next) => {
|
||||||
|
heart.beat()
|
||||||
|
|
||||||
|
// Add common variables routes can use.
|
||||||
|
req.args = args
|
||||||
|
req.heart = heart
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(common)
|
||||||
|
wsApp.use(common)
|
||||||
|
|
||||||
|
app.use(async (req, res, next) => {
|
||||||
|
// If we're handling TLS ensure all requests are redirected to HTTPS.
|
||||||
|
// TODO: This does *NOT* work if you have a base path since to specify the
|
||||||
|
// protocol we need to specify the whole path.
|
||||||
|
if (args.cert && !(req.connection as tls.TLSSocket).encrypted) {
|
||||||
|
return res.redirect(`https://${req.headers.host}${req.originalUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return robots.txt.
|
||||||
|
if (req.originalUrl === "/robots.txt") {
|
||||||
|
const resourcePath = path.resolve(rootPath, "src/browser/robots.txt")
|
||||||
|
res.set("Content-Type", getMediaMime(resourcePath))
|
||||||
|
return res.send(await fs.readFile(resourcePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use("/", domainProxy.router)
|
||||||
|
wsApp.use("/", domainProxy.wsRouter.router)
|
||||||
|
|
||||||
|
app.use("/", vscode.router)
|
||||||
|
wsApp.use("/", vscode.wsRouter.router)
|
||||||
|
app.use("/vscode", vscode.router)
|
||||||
|
wsApp.use("/vscode", vscode.wsRouter.router)
|
||||||
|
|
||||||
|
app.use("/healthz", health.router)
|
||||||
|
|
||||||
|
if (args.auth === AuthType.Password) {
|
||||||
|
app.use("/login", login.router)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use("/proxy", proxy.router)
|
||||||
|
wsApp.use("/proxy", proxy.wsRouter.router)
|
||||||
|
|
||||||
|
app.use("/static", _static.router)
|
||||||
|
app.use("/update", update.router)
|
||||||
|
|
||||||
|
const papi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH)
|
||||||
|
await papi.loadPlugins()
|
||||||
|
papi.mount(app)
|
||||||
|
app.use("/api/applications", apps.router(papi))
|
||||||
|
|
||||||
|
app.use(() => {
|
||||||
|
throw new HttpError("Not Found", HttpCode.NotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorHandler: express.ErrorRequestHandler = async (err, req, res) => {
|
||||||
|
if (err.code === "ENOENT" || err.code === "EISDIR") {
|
||||||
|
err.status = HttpCode.NotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = err.status ?? err.statusCode ?? 500
|
||||||
|
res.status(status)
|
||||||
|
|
||||||
|
if (req.accepts("application/json")) {
|
||||||
|
res.json({
|
||||||
|
error: err.message,
|
||||||
|
...(err.details || {}),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
const wsErrorHandler: express.ErrorRequestHandler = async (err, req) => {
|
||||||
|
logger.error(`${err.message} ${err.stack}`)
|
||||||
|
;(req as WebsocketRequest).ws.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
wsApp.use(wsErrorHandler)
|
||||||
|
}
|
95
src/node/routes/login.ts
Normal file
95
src/node/routes/login.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { Router, Request } from "express"
|
||||||
|
import { promises as fs } from "fs"
|
||||||
|
import { RateLimiter as Limiter } from "limiter"
|
||||||
|
import * as path from "path"
|
||||||
|
import safeCompare from "safe-compare"
|
||||||
|
import { rootPath } from "../constants"
|
||||||
|
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
|
||||||
|
import { hash, humanPath } from "../util"
|
||||||
|
|
||||||
|
enum Cookie {
|
||||||
|
Key = "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiter wraps around the limiter library for logins.
|
||||||
|
// It allows 2 logins every minute and 12 logins every hour.
|
||||||
|
class RateLimiter {
|
||||||
|
private readonly minuteLimiter = new Limiter(2, "minute")
|
||||||
|
private readonly hourLimiter = new Limiter(12, "hour")
|
||||||
|
|
||||||
|
public try(): boolean {
|
||||||
|
if (this.minuteLimiter.tryRemoveTokens(1)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return this.hourLimiter.tryRemoveTokens(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoot = async (req: Request, error?: Error): Promise<string> => {
|
||||||
|
const content = await fs.readFile(path.join(rootPath, "src/browser/pages/login.html"), "utf8")
|
||||||
|
let passwordMsg = `Check the config file at ${humanPath(req.args.config)} for the password.`
|
||||||
|
if (req.args.usingEnvPassword) {
|
||||||
|
passwordMsg = "Password was set from $PASSWORD."
|
||||||
|
}
|
||||||
|
return replaceTemplates(
|
||||||
|
req,
|
||||||
|
content
|
||||||
|
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
|
||||||
|
.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const limiter = new RateLimiter()
|
||||||
|
|
||||||
|
export const router = Router()
|
||||||
|
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
const to = (typeof req.query.to === "string" && req.query.to) || "/"
|
||||||
|
if (authenticated(req)) {
|
||||||
|
return redirect(req, res, to, { to: undefined })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get("/", async (req, res) => {
|
||||||
|
res.send(await getRoot(req))
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post("/", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!limiter.try()) {
|
||||||
|
throw new Error("Login rate limited!")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.body.password) {
|
||||||
|
throw new Error("Missing password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.args.password && safeCompare(req.body.password, req.args.password)) {
|
||||||
|
// The hash does not add any actual security but we do it for
|
||||||
|
// obfuscation purposes (and as a side effect it handles escaping).
|
||||||
|
res.cookie(Cookie.Key, hash(req.body.password), {
|
||||||
|
domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
|
||||||
|
path: req.body.base || "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
})
|
||||||
|
|
||||||
|
const to = (typeof req.query.to === "string" && req.query.to) || "/"
|
||||||
|
return redirect(req, res, to, { to: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
"Failed login attempt",
|
||||||
|
JSON.stringify({
|
||||||
|
xForwardedFor: req.headers["x-forwarded-for"],
|
||||||
|
remoteAddress: req.connection.remoteAddress,
|
||||||
|
userAgent: req.headers["user-agent"],
|
||||||
|
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
throw new Error("Incorrect password")
|
||||||
|
} catch (error) {
|
||||||
|
res.send(await getRoot(req, error))
|
||||||
|
}
|
||||||
|
})
|
47
src/node/routes/pathProxy.ts
Normal file
47
src/node/routes/pathProxy.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Request, Router } from "express"
|
||||||
|
import qs from "qs"
|
||||||
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
|
import { normalize } from "../../common/util"
|
||||||
|
import { authenticated, ensureAuthenticated, redirect } from "../http"
|
||||||
|
import { proxy } from "../proxy"
|
||||||
|
import { Router as WsRouter } from "../wsRouter"
|
||||||
|
|
||||||
|
export const router = Router()
|
||||||
|
|
||||||
|
const getProxyTarget = (req: Request, rewrite: boolean): string => {
|
||||||
|
if (rewrite) {
|
||||||
|
const query = qs.stringify(req.query)
|
||||||
|
return `http://0.0.0.0:${req.params.port}/${req.params[0] || ""}${query ? `?${query}` : ""}`
|
||||||
|
}
|
||||||
|
return `http://0.0.0.0:${req.params.port}/${req.originalUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
router.all("/(:port)(/*)?", (req, res) => {
|
||||||
|
if (!authenticated(req)) {
|
||||||
|
// If visiting the root (/:port only) redirect to the login page.
|
||||||
|
if (!req.params[0] || req.params[0] === "/") {
|
||||||
|
const to = normalize(`${req.baseUrl}${req.path}`)
|
||||||
|
return redirect(req, res, "login", {
|
||||||
|
to: to !== "/" ? to : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute redirects need to be based on the subpath when rewriting.
|
||||||
|
;(req as any).base = `${req.baseUrl}/${req.params.port}`
|
||||||
|
|
||||||
|
proxy.web(req, res, {
|
||||||
|
ignorePath: true,
|
||||||
|
target: getProxyTarget(req, true),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const wsRouter = WsRouter()
|
||||||
|
|
||||||
|
wsRouter.ws("/(:port)(/*)?", ensureAuthenticated, (req) => {
|
||||||
|
proxy.ws(req, req.ws, req.head, {
|
||||||
|
ignorePath: true,
|
||||||
|
target: getProxyTarget(req, true),
|
||||||
|
})
|
||||||
|
})
|
64
src/node/routes/static.ts
Normal file
64
src/node/routes/static.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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 { rootPath } from "../constants"
|
||||||
|
import { authenticated, replaceTemplates } from "../http"
|
||||||
|
import { getMediaMime, pathToFsPath } from "../util"
|
||||||
|
|
||||||
|
export const router = Router()
|
||||||
|
|
||||||
|
// The commit is for caching.
|
||||||
|
router.get("/(:commit)(/*)?", async (req, res) => {
|
||||||
|
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.
|
||||||
|
if (!resourcePath.startsWith(rootPath) && !authenticated(req)) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by VS Code to load extensions into the web worker.
|
||||||
|
*/
|
||||||
|
const tar = Array.isArray(req.query.tar) ? req.query.tar[0] : req.query.tar
|
||||||
|
if (typeof tar === "string") {
|
||||||
|
let stream: Readable = tarFs.pack(pathToFsPath(tar))
|
||||||
|
if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) {
|
||||||
|
logger.debug("gzipping tar", field("path", resourcePath))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
18
src/node/routes/update.ts
Normal file
18
src/node/routes/update.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Router } from "express"
|
||||||
|
import { version } from "../constants"
|
||||||
|
import { ensureAuthenticated } from "../http"
|
||||||
|
import { UpdateProvider } from "../update"
|
||||||
|
|
||||||
|
export const router = Router()
|
||||||
|
|
||||||
|
const provider = new UpdateProvider()
|
||||||
|
|
||||||
|
router.get("/", ensureAuthenticated, async (req, res) => {
|
||||||
|
const update = await provider.getUpdate(req.query.force === "true")
|
||||||
|
res.json({
|
||||||
|
checked: update.checked,
|
||||||
|
latest: update.version,
|
||||||
|
current: version,
|
||||||
|
isLatest: provider.isLatestVersion(update),
|
||||||
|
})
|
||||||
|
})
|
105
src/node/routes/vscode.ts
Normal file
105
src/node/routes/vscode.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import * as crypto from "crypto"
|
||||||
|
import { Router } from "express"
|
||||||
|
import { promises as fs } from "fs"
|
||||||
|
import * as path from "path"
|
||||||
|
import { commit, 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"
|
||||||
|
|
||||||
|
export const router = Router()
|
||||||
|
|
||||||
|
const vscode = new VscodeProvider()
|
||||||
|
|
||||||
|
router.get("/", async (req, res) => {
|
||||||
|
if (!authenticated(req)) {
|
||||||
|
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 = commit === "development" ? "It might not have finished compiling." : ""
|
||||||
|
throw new Error(`VS Code failed to load. ${devMessage} ${error.message}`)
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
])
|
||||||
|
|
||||||
|
options.productConfiguration.codeServerVersion = version
|
||||||
|
|
||||||
|
res.send(
|
||||||
|
replaceTemplates(
|
||||||
|
req,
|
||||||
|
// Uncomment prod blocks if not in development. TODO: Would this be
|
||||||
|
// better as a build step? Or maintain two HTML files again?
|
||||||
|
commit !== "development" ? content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "") : content,
|
||||||
|
{
|
||||||
|
disableTelemetry: !!req.args["disable-telemetry"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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) => {
|
||||||
|
if (typeof req.query.path === "string") {
|
||||||
|
res.set("Content-Type", getMediaMime(req.query.path))
|
||||||
|
res.send(await fs.readFile(pathToFsPath(req.query.path)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by VS Code to load files.
|
||||||
|
*/
|
||||||
|
router.get("/vscode-remote-resource(/*)?", ensureAuthenticated, async (req, res) => {
|
||||||
|
if (typeof req.query.path === "string") {
|
||||||
|
res.set("Content-Type", getMediaMime(req.query.path))
|
||||||
|
res.send(await fs.readFile(pathToFsPath(req.query.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])),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const wsRouter = WsRouter()
|
||||||
|
|
||||||
|
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")
|
||||||
|
req.ws.write(
|
||||||
|
[
|
||||||
|
"HTTP/1.1 101 Switching Protocols",
|
||||||
|
"Upgrade: websocket",
|
||||||
|
"Connection: Upgrade",
|
||||||
|
`Sec-WebSocket-Accept: ${reply}`,
|
||||||
|
].join("\r\n") + "\r\n\r\n",
|
||||||
|
)
|
||||||
|
await vscode.sendWebsocket(req.ws, req.query)
|
||||||
|
})
|
@ -1,7 +1,7 @@
|
|||||||
import { logger } from "@coder/logger"
|
import { logger } from "@coder/logger"
|
||||||
|
import { Query } from "express-serve-static-core"
|
||||||
import * as fs from "fs-extra"
|
import * as fs from "fs-extra"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { Route } from "./http"
|
|
||||||
import { paths } from "./util"
|
import { paths } from "./util"
|
||||||
|
|
||||||
export type Settings = { [key: string]: Settings | string | boolean | number }
|
export type Settings = { [key: string]: Settings | string | boolean | number }
|
||||||
@ -58,7 +58,7 @@ export interface CoderSettings extends UpdateSettings {
|
|||||||
url: string
|
url: string
|
||||||
workspace: boolean
|
workspace: boolean
|
||||||
}
|
}
|
||||||
query: Route["query"]
|
query: Query
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
import { field, logger } from "@coder/logger"
|
||||||
import * as http from "http"
|
import * as http from "http"
|
||||||
import * as https from "https"
|
import * as https from "https"
|
||||||
import * as path from "path"
|
|
||||||
import * as semver from "semver"
|
import * as semver from "semver"
|
||||||
import * as url from "url"
|
import * as url from "url"
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
import { version } from "./constants"
|
||||||
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "./settings"
|
||||||
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings"
|
|
||||||
|
|
||||||
export interface Update {
|
export interface Update {
|
||||||
checked: number
|
checked: number
|
||||||
@ -18,15 +16,13 @@ export interface LatestResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP provider for checking updates (does not download/install them).
|
* Provide update information.
|
||||||
*/
|
*/
|
||||||
export class UpdateHttpProvider extends HttpProvider {
|
export class UpdateProvider {
|
||||||
private update?: Promise<Update>
|
private update?: Promise<Update>
|
||||||
private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks.
|
private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks.
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
options: HttpProviderOptions,
|
|
||||||
public readonly enabled: boolean,
|
|
||||||
/**
|
/**
|
||||||
* The URL for getting the latest version of code-server. Should return JSON
|
* The URL for getting the latest version of code-server. Should return JSON
|
||||||
* that fulfills `LatestResponse`.
|
* that fulfills `LatestResponse`.
|
||||||
@ -37,37 +33,7 @@ export class UpdateHttpProvider extends HttpProvider {
|
|||||||
* settings will be used.
|
* settings will be used.
|
||||||
*/
|
*/
|
||||||
private readonly settings: SettingsProvider<UpdateSettings> = globalSettings,
|
private readonly settings: SettingsProvider<UpdateSettings> = globalSettings,
|
||||||
) {
|
) {}
|
||||||
super(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
this.ensureAuthenticated(request)
|
|
||||||
this.ensureMethod(request)
|
|
||||||
|
|
||||||
if (!this.isRoot(route)) {
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.enabled) {
|
|
||||||
throw new Error("update checks are disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (route.base) {
|
|
||||||
case "/check":
|
|
||||||
case "/": {
|
|
||||||
const update = await this.getUpdate(route.base === "/check")
|
|
||||||
return {
|
|
||||||
content: {
|
|
||||||
...update,
|
|
||||||
isLatest: this.isLatestVersion(update),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query for and return the latest update.
|
* Query for and return the latest update.
|
||||||
@ -89,7 +55,7 @@ export class UpdateHttpProvider extends HttpProvider {
|
|||||||
if (!update || update.checked + this.updateInterval < now) {
|
if (!update || update.checked + this.updateInterval < now) {
|
||||||
const buffer = await this.request(this.latestUrl)
|
const buffer = await this.request(this.latestUrl)
|
||||||
const data = JSON.parse(buffer.toString()) as LatestResponse
|
const data = JSON.parse(buffer.toString()) as LatestResponse
|
||||||
update = { checked: now, version: data.name }
|
update = { checked: now, version: data.name.replace(/^v/, "") }
|
||||||
await this.settings.write({ update })
|
await this.settings.write({ update })
|
||||||
}
|
}
|
||||||
logger.debug("got latest version", field("latest", update.version))
|
logger.debug("got latest version", field("latest", update.version))
|
||||||
@ -103,15 +69,10 @@ export class UpdateHttpProvider extends HttpProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get currentVersion(): string {
|
|
||||||
return require(path.resolve(__dirname, "../../../package.json")).version
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return true if the currently installed version is the latest.
|
* Return true if the currently installed version is the latest.
|
||||||
*/
|
*/
|
||||||
public isLatestVersion(latest: Update): boolean {
|
public isLatestVersion(latest: Update): boolean {
|
||||||
const version = this.currentVersion
|
|
||||||
logger.debug("comparing versions", field("current", version), field("latest", latest.version))
|
logger.debug("comparing versions", field("current", version), field("latest", latest.version))
|
||||||
try {
|
try {
|
||||||
return latest.version === version || semver.lt(latest.version, version)
|
return latest.version === version || semver.lt(latest.version, version)
|
||||||
@ -144,22 +105,20 @@ export class UpdateHttpProvider extends HttpProvider {
|
|||||||
logger.debug("Making request", field("uri", uri))
|
logger.debug("Making request", field("uri", uri))
|
||||||
const httpx = uri.startsWith("https") ? https : http
|
const httpx = uri.startsWith("https") ? https : http
|
||||||
const client = httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => {
|
const client = httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => {
|
||||||
if (
|
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) {
|
||||||
response.statusCode &&
|
return reject(new Error(`${uri}: ${response.statusCode || "500"}`))
|
||||||
response.statusCode >= 300 &&
|
}
|
||||||
response.statusCode < 400 &&
|
|
||||||
response.headers.location
|
if (response.statusCode >= 300) {
|
||||||
) {
|
|
||||||
++redirects
|
++redirects
|
||||||
|
response.destroy()
|
||||||
if (redirects > maxRedirects) {
|
if (redirects > maxRedirects) {
|
||||||
return reject(new Error("reached max redirects"))
|
return reject(new Error("reached max redirects"))
|
||||||
}
|
}
|
||||||
response.destroy()
|
if (!response.headers.location) {
|
||||||
return request(url.resolve(uri, response.headers.location))
|
return reject(new Error("received redirect with no location header"))
|
||||||
}
|
}
|
||||||
|
return request(url.resolve(uri, response.headers.location))
|
||||||
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) {
|
|
||||||
return reject(new Error(`${uri}: ${response.statusCode || "500"}`))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(response)
|
resolve(response)
|
@ -281,3 +281,12 @@ export function canConnect(path: string): Promise<boolean> {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isFile = async (path: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(path)
|
||||||
|
return stat.isFile()
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
210
src/node/vscode.ts
Normal file
210
src/node/vscode.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { field, logger } from "@coder/logger"
|
||||||
|
import * as cp from "child_process"
|
||||||
|
import * as net from "net"
|
||||||
|
import * as path from "path"
|
||||||
|
import * as ipc from "../../lib/vscode/src/vs/server/ipc"
|
||||||
|
import { arrayify, generateUuid } from "../common/util"
|
||||||
|
import { rootPath } from "./constants"
|
||||||
|
import { settings } from "./settings"
|
||||||
|
import { SocketProxyProvider } from "./socket"
|
||||||
|
import { isFile } from "./util"
|
||||||
|
import { ipcMain } from "./wrapper"
|
||||||
|
|
||||||
|
export class VscodeProvider {
|
||||||
|
public readonly serverRootPath: string
|
||||||
|
public readonly vsRootPath: string
|
||||||
|
private _vscode?: Promise<cp.ChildProcess>
|
||||||
|
private timeoutInterval = 10000 // 10s, matches VS Code's timeouts.
|
||||||
|
private readonly socketProvider = new SocketProxyProvider()
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this.vsRootPath = path.resolve(rootPath, "lib/vscode")
|
||||||
|
this.serverRootPath = path.join(this.vsRootPath, "out/vs/server")
|
||||||
|
ipcMain.onDispose(() => this.dispose())
|
||||||
|
}
|
||||||
|
|
||||||
|
public async dispose(): Promise<void> {
|
||||||
|
this.socketProvider.stop()
|
||||||
|
if (this._vscode) {
|
||||||
|
const vscode = await this._vscode
|
||||||
|
vscode.removeAllListeners()
|
||||||
|
vscode.kill()
|
||||||
|
this._vscode = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initialize(
|
||||||
|
options: Omit<ipc.VscodeOptions, "startPath">,
|
||||||
|
query: ipc.Query,
|
||||||
|
): Promise<ipc.WorkbenchOptions> {
|
||||||
|
const { lastVisited } = await settings.read()
|
||||||
|
const startPath = await this.getFirstPath([
|
||||||
|
{ url: query.workspace, workspace: true },
|
||||||
|
{ url: query.folder, workspace: false },
|
||||||
|
options.args._ && options.args._.length > 0
|
||||||
|
? { url: path.resolve(options.args._[options.args._.length - 1]) }
|
||||||
|
: undefined,
|
||||||
|
lastVisited,
|
||||||
|
])
|
||||||
|
|
||||||
|
settings.write({
|
||||||
|
lastVisited: startPath,
|
||||||
|
query,
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = generateUuid()
|
||||||
|
const vscode = await this.fork()
|
||||||
|
|
||||||
|
logger.debug("setting up vs code...")
|
||||||
|
|
||||||
|
this.send(
|
||||||
|
{
|
||||||
|
type: "init",
|
||||||
|
id,
|
||||||
|
options: {
|
||||||
|
...options,
|
||||||
|
startPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vscode,
|
||||||
|
)
|
||||||
|
|
||||||
|
const message = await this.onMessage(vscode, (message): message is ipc.OptionsMessage => {
|
||||||
|
// There can be parallel initializations so wait for the right ID.
|
||||||
|
return message.type === "options" && message.id === id
|
||||||
|
})
|
||||||
|
|
||||||
|
return message.options
|
||||||
|
}
|
||||||
|
|
||||||
|
private fork(): Promise<cp.ChildProcess> {
|
||||||
|
if (this._vscode) {
|
||||||
|
return this._vscode
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("forking vs code...")
|
||||||
|
const vscode = cp.fork(path.join(this.serverRootPath, "fork"))
|
||||||
|
|
||||||
|
const dispose = () => {
|
||||||
|
vscode.removeAllListeners()
|
||||||
|
vscode.kill()
|
||||||
|
this._vscode = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
vscode.on("error", (error: Error) => {
|
||||||
|
logger.error(error.message)
|
||||||
|
if (error.stack) {
|
||||||
|
logger.debug(error.stack)
|
||||||
|
}
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
vscode.on("exit", (code) => {
|
||||||
|
logger.error(`VS Code exited unexpectedly with code ${code}`)
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
this._vscode = this.onMessage(vscode, (message): message is ipc.ReadyMessage => {
|
||||||
|
return message.type === "ready"
|
||||||
|
}).then(() => vscode)
|
||||||
|
|
||||||
|
return this._vscode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to a single message from a process. Reject if the process errors,
|
||||||
|
* exits, or times out.
|
||||||
|
*
|
||||||
|
* `fn` is a function that determines whether the message is the one we're
|
||||||
|
* waiting for.
|
||||||
|
*/
|
||||||
|
private onMessage<T extends ipc.VscodeMessage>(
|
||||||
|
proc: cp.ChildProcess,
|
||||||
|
fn: (message: ipc.VscodeMessage) => message is T,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
proc.off("error", onError)
|
||||||
|
proc.off("exit", onExit)
|
||||||
|
proc.off("message", onMessage)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error("timed out"))
|
||||||
|
}, this.timeoutInterval)
|
||||||
|
|
||||||
|
const onError = (error: Error) => {
|
||||||
|
cleanup()
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onExit = (code: number | null) => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error(`VS Code exited unexpectedly with code ${code}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMessage = (message: ipc.VscodeMessage) => {
|
||||||
|
logger.trace("got message from vscode", field("message", message))
|
||||||
|
if (fn(message)) {
|
||||||
|
cleanup()
|
||||||
|
resolve(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.on("message", onMessage)
|
||||||
|
proc.on("error", onError)
|
||||||
|
proc.on("exit", onExit)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VS Code expects a raw socket. It will handle all the web socket frames.
|
||||||
|
*/
|
||||||
|
public async sendWebsocket(socket: net.Socket, query: ipc.Query): Promise<void> {
|
||||||
|
const vscode = await this._vscode
|
||||||
|
// TLS sockets cannot be transferred to child processes so we need an
|
||||||
|
// in-between. Non-TLS sockets will be returned as-is.
|
||||||
|
const socketProxy = await this.socketProvider.createProxy(socket)
|
||||||
|
this.send({ type: "socket", query }, vscode, socketProxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(message: ipc.CodeServerMessage, vscode?: cp.ChildProcess, socket?: net.Socket): void {
|
||||||
|
if (!vscode || vscode.killed) {
|
||||||
|
throw new Error("vscode is not running")
|
||||||
|
}
|
||||||
|
vscode.send(message, socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose the first non-empty path from the provided array.
|
||||||
|
*
|
||||||
|
* Each array item consists of `url` and an optional `workspace` boolean that
|
||||||
|
* indicates whether that url is for a workspace.
|
||||||
|
*
|
||||||
|
* `url` can be a fully qualified URL or just the path portion.
|
||||||
|
*
|
||||||
|
* `url` can also be a query object to make it easier to pass in query
|
||||||
|
* variables directly but anything that isn't a string or string array is not
|
||||||
|
* valid and will be ignored.
|
||||||
|
*/
|
||||||
|
private async getFirstPath(
|
||||||
|
startPaths: Array<{ url?: string | string[] | ipc.Query | ipc.Query[]; workspace?: boolean } | undefined>,
|
||||||
|
): Promise<ipc.StartPath | undefined> {
|
||||||
|
for (let i = 0; i < startPaths.length; ++i) {
|
||||||
|
const startPath = startPaths[i]
|
||||||
|
const url = arrayify(startPath && startPath.url).find((p) => !!p)
|
||||||
|
if (startPath && url && typeof url === "string") {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
// The only time `workspace` is undefined is for the command-line
|
||||||
|
// argument, in which case it's a path (not a URL) so we can stat it
|
||||||
|
// without having to parse it.
|
||||||
|
workspace: typeof startPath.workspace !== "undefined" ? startPath.workspace : await isFile(url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
@ -39,13 +39,14 @@ export class IpcMain {
|
|||||||
process.on("SIGTERM", () => this._onDispose.emit("SIGTERM"))
|
process.on("SIGTERM", () => this._onDispose.emit("SIGTERM"))
|
||||||
process.on("exit", () => this._onDispose.emit(undefined))
|
process.on("exit", () => this._onDispose.emit(undefined))
|
||||||
|
|
||||||
this.onDispose((signal) => {
|
this.onDispose((signal, wait) => {
|
||||||
// Remove listeners to avoid possibly triggering disposal again.
|
// Remove listeners to avoid possibly triggering disposal again.
|
||||||
process.removeAllListeners()
|
process.removeAllListeners()
|
||||||
|
|
||||||
// Let any other handlers run first then exit.
|
// Try waiting for other handlers run first then exit.
|
||||||
logger.debug(`${parentPid ? "inner process" : "wrapper"} ${process.pid} disposing`, field("code", signal))
|
logger.debug(`${parentPid ? "inner process" : "wrapper"} ${process.pid} disposing`, field("code", signal))
|
||||||
setTimeout(() => this.exit(0), 0)
|
wait.then(() => this.exit(0))
|
||||||
|
setTimeout(() => this.exit(0), 5000)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Kill the inner process if the parent dies. This is for the case where the
|
// Kill the inner process if the parent dies. This is for the case where the
|
||||||
|
57
src/node/wsRouter.ts
Normal file
57
src/node/wsRouter.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import * as express from "express"
|
||||||
|
import * as expressCore from "express-serve-static-core"
|
||||||
|
import * as http from "http"
|
||||||
|
import * as net from "net"
|
||||||
|
|
||||||
|
export const handleUpgrade = (app: express.Express, server: http.Server): void => {
|
||||||
|
server.on("upgrade", (req, socket, head) => {
|
||||||
|
socket.pause()
|
||||||
|
|
||||||
|
req.ws = socket
|
||||||
|
req.head = head
|
||||||
|
req._ws_handled = false
|
||||||
|
|
||||||
|
// Send the request off to be handled by Express.
|
||||||
|
;(app as any).handle(req, new http.ServerResponse(req), () => {
|
||||||
|
if (!req._ws_handled) {
|
||||||
|
socket.end("HTTP/1.1 404 Not Found\r\n\r\n")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsocketRequest extends express.Request {
|
||||||
|
ws: net.Socket
|
||||||
|
head: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalWebsocketRequest extends WebsocketRequest {
|
||||||
|
_ws_handled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebSocketHandler = (
|
||||||
|
req: WebsocketRequest,
|
||||||
|
res: express.Response,
|
||||||
|
next: express.NextFunction,
|
||||||
|
) => void | Promise<void>
|
||||||
|
|
||||||
|
export class WebsocketRouter {
|
||||||
|
public readonly router = express.Router()
|
||||||
|
|
||||||
|
public ws(route: expressCore.PathParams, ...handlers: WebSocketHandler[]): void {
|
||||||
|
this.router.get(
|
||||||
|
route,
|
||||||
|
...handlers.map((handler) => {
|
||||||
|
const wrapped: express.Handler = (req, res, next) => {
|
||||||
|
;(req as InternalWebsocketRequest)._ws_handled = true
|
||||||
|
return handler(req as WebsocketRequest, res, next)
|
||||||
|
}
|
||||||
|
return wrapped
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Router(): WebsocketRouter {
|
||||||
|
return new WebsocketRouter()
|
||||||
|
}
|
@ -14,17 +14,23 @@ type Mutable<T> = {
|
|||||||
describe("parser", () => {
|
describe("parser", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete process.env.LOG_LEVEL
|
delete process.env.LOG_LEVEL
|
||||||
|
delete process.env.PASSWORD
|
||||||
})
|
})
|
||||||
|
|
||||||
// The parser should not set any defaults so the caller can determine what
|
// The parser should not set any defaults so the caller can determine what
|
||||||
// values the user actually set. These are only set after explicitly calling
|
// values the user actually set. These are only set after explicitly calling
|
||||||
// `setDefaults`.
|
// `setDefaults`.
|
||||||
const defaults = {
|
const defaults = {
|
||||||
|
auth: "password",
|
||||||
|
host: "localhost",
|
||||||
|
port: 8080,
|
||||||
|
"proxy-domain": [],
|
||||||
|
usingEnvPassword: false,
|
||||||
"extensions-dir": path.join(paths.data, "extensions"),
|
"extensions-dir": path.join(paths.data, "extensions"),
|
||||||
"user-data-dir": paths.data,
|
"user-data-dir": paths.data,
|
||||||
}
|
}
|
||||||
|
|
||||||
it("should set defaults", () => {
|
it("should parse nothing", () => {
|
||||||
assert.deepEqual(parse([]), { _: [] })
|
assert.deepEqual(parse([]), { _: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -232,6 +238,71 @@ describe("parser", () => {
|
|||||||
"proxy-domain": ["*.coder.com", "test.com"],
|
"proxy-domain": ["*.coder.com", "test.com"],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should enforce cert-key with cert value or otherwise generate one", async () => {
|
||||||
|
const args = parse(["--cert"])
|
||||||
|
assert.deepEqual(args, {
|
||||||
|
_: [],
|
||||||
|
cert: {
|
||||||
|
value: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.throws(() => parse(["--cert", "test"]), /--cert-key is missing/)
|
||||||
|
assert.deepEqual(await setDefaults(args), {
|
||||||
|
_: [],
|
||||||
|
...defaults,
|
||||||
|
cert: {
|
||||||
|
value: path.join(paths.data, "localhost.crt"),
|
||||||
|
},
|
||||||
|
"cert-key": path.join(paths.data, "localhost.key"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should override with --link", async () => {
|
||||||
|
const args = parse("--cert test --cert-key test --socket test --host 0.0.0.0 --port 8888 --link test".split(" "))
|
||||||
|
assert.deepEqual(await setDefaults(args), {
|
||||||
|
_: [],
|
||||||
|
...defaults,
|
||||||
|
auth: "none",
|
||||||
|
host: "localhost",
|
||||||
|
link: {
|
||||||
|
value: "test",
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
cert: undefined,
|
||||||
|
"cert-key": path.resolve("test"),
|
||||||
|
socket: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use env var password", async () => {
|
||||||
|
process.env.PASSWORD = "test"
|
||||||
|
const args = parse([])
|
||||||
|
assert.deepEqual(args, {
|
||||||
|
_: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(await setDefaults(args), {
|
||||||
|
...defaults,
|
||||||
|
_: [],
|
||||||
|
password: "test",
|
||||||
|
usingEnvPassword: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should filter proxy domains", async () => {
|
||||||
|
const args = parse(["--proxy-domain", "*.coder.com", "--proxy-domain", "coder.com", "--proxy-domain", "coder.org"])
|
||||||
|
assert.deepEqual(args, {
|
||||||
|
_: [],
|
||||||
|
"proxy-domain": ["*.coder.com", "coder.com", "coder.org"],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(await setDefaults(args), {
|
||||||
|
...defaults,
|
||||||
|
_: [],
|
||||||
|
"proxy-domain": ["coder.com", "coder.org"],
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("cli", () => {
|
describe("cli", () => {
|
||||||
|
62
test/plugin.test.ts
Normal file
62
test/plugin.test.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { logger } from "@coder/logger"
|
||||||
|
import * as express from "express"
|
||||||
|
import * as fs from "fs"
|
||||||
|
import { describe } from "mocha"
|
||||||
|
import * as path from "path"
|
||||||
|
import * as supertest from "supertest"
|
||||||
|
import { PluginAPI } from "../src/node/plugin"
|
||||||
|
import * as apps from "../src/node/routes/apps"
|
||||||
|
const fsp = fs.promises
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use $LOG_LEVEL=debug to see debug logs.
|
||||||
|
*/
|
||||||
|
describe("plugin", () => {
|
||||||
|
let papi: PluginAPI
|
||||||
|
let app: express.Application
|
||||||
|
let agent: supertest.SuperAgentTest
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
papi = new PluginAPI(logger, path.resolve(__dirname, "test-plugin") + ":meow")
|
||||||
|
await papi.loadPlugins()
|
||||||
|
|
||||||
|
app = express.default()
|
||||||
|
papi.mount(app)
|
||||||
|
|
||||||
|
app.use("/api/applications", apps.router(papi))
|
||||||
|
|
||||||
|
agent = supertest.agent(app)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("/api/applications", async () => {
|
||||||
|
await agent.get("/api/applications").expect(200, [
|
||||||
|
{
|
||||||
|
name: "Test App",
|
||||||
|
version: "4.0.0",
|
||||||
|
|
||||||
|
description: "This app does XYZ.",
|
||||||
|
iconPath: "/test-plugin/test-app/icon.svg",
|
||||||
|
homepageURL: "https://example.com",
|
||||||
|
path: "/test-plugin/test-app",
|
||||||
|
|
||||||
|
plugin: {
|
||||||
|
name: "test-plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
modulePath: path.join(__dirname, "test-plugin"),
|
||||||
|
|
||||||
|
displayName: "Test Plugin",
|
||||||
|
description: "Plugin used in code-server tests.",
|
||||||
|
routerPath: "/test-plugin",
|
||||||
|
homepageURL: "https://example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("/test-plugin/test-app", async () => {
|
||||||
|
const indexHTML = await fsp.readFile(path.join(__dirname, "test-plugin/public/index.html"), {
|
||||||
|
encoding: "utf8",
|
||||||
|
})
|
||||||
|
await agent.get("/test-plugin/test-app").expect(200, indexHTML)
|
||||||
|
})
|
||||||
|
})
|
1
test/test-plugin/.gitignore
vendored
Normal file
1
test/test-plugin/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
out
|
6
test/test-plugin/Makefile
Normal file
6
test/test-plugin/Makefile
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
out/index.js: src/index.ts
|
||||||
|
# Typescript always emits, even on errors.
|
||||||
|
yarn build || rm out/index.js
|
||||||
|
|
||||||
|
node_modules: package.json yarn.lock
|
||||||
|
yarn
|
19
test/test-plugin/package.json
Normal file
19
test/test-plugin/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"name": "test-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"code-server": "^3.6.0"
|
||||||
|
},
|
||||||
|
"main": "out/index.js",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.8",
|
||||||
|
"typescript": "^4.0.5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.17.1"
|
||||||
|
}
|
||||||
|
}
|
1
test/test-plugin/public/icon.svg
Normal file
1
test/test-plugin/public/icon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg width="121" height="131" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient x1="9.612%" y1="66.482%" x2="89.899%" y2="33.523%" id="a"><stop stop-color="#FCEE39" offset="0%"/><stop stop-color="#F37B3D" offset="100%"/></linearGradient><linearGradient x1="8.601%" y1="15.03%" x2="99.641%" y2="89.058%" id="b"><stop stop-color="#EF5A6B" offset="0%"/><stop stop-color="#F26F4E" offset="57%"/><stop stop-color="#F37B3D" offset="100%"/></linearGradient><linearGradient x1="90.118%" y1="69.931%" x2="17.938%" y2="38.628%" id="c"><stop stop-color="#7C59A4" offset="0%"/><stop stop-color="#AF4C92" offset="38.52%"/><stop stop-color="#DC4183" offset="76.54%"/><stop stop-color="#ED3D7D" offset="95.7%"/></linearGradient><linearGradient x1="91.376%" y1="19.144%" x2="18.895%" y2="70.21%" id="d"><stop stop-color="#EF5A6B" offset="0%"/><stop stop-color="#EE4E72" offset="36.4%"/><stop stop-color="#ED3D7D" offset="100%"/></linearGradient></defs><g fill="none"><path d="M118.623 71.8c.9-.8 1.4-1.9 1.5-3.2.1-2.6-1.8-4.7-4.4-4.9-1.2-.1-2.4.4-3.3 1.1l-83.8 45.9c-1.9.8-3.6 2.2-4.7 4.1-2.9 4.8-1.3 11 3.6 13.9 3.4 2 7.5 1.8 10.7-.2.2-.2.5-.3.7-.5l78-54.8c.4-.3 1.5-1.1 1.7-1.4z" fill="url(#a)" transform="translate(-.023)"/><path d="M118.823 65.1l-63.8-62.6c-1.4-1.5-3.4-2.5-5.7-2.5-4.3 0-7.7 3.5-7.7 7.7 0 2.1.8 3.9 2.1 5.3.4.4.8.7 1.2 1l67.4 57.7c.8.7 1.8 1.2 3 1.3 2.6.1 4.7-1.8 4.9-4.4 0-1.3-.5-2.6-1.4-3.5z" fill="url(#b)" transform="translate(-.023)"/><path d="M57.123 59.5c-.1 0-39.4-31-40.2-31.5l-1.8-.9c-5.8-2.2-12.2.8-14.4 6.6-1.9 5.1.2 10.7 4.6 13.4.7.4 1.3.7 2 .9.4.2 45.4 18.8 45.4 18.8 1.8.8 3.9.3 5.1-1.2 1.5-1.9 1.2-4.6-.7-6.1z" fill="url(#c)" transform="translate(-.023)"/><path d="M49.323 0c-1.7 0-3.3.6-4.6 1.5l-39.8 26.8c-.1.1-.2.1-.2.2h-.1c-1.7 1.2-3.1 3-3.9 5.1-2.2 5.8.8 12.3 6.6 14.4 3.6 1.4 7.5.7 10.4-1.4.7-.5 1.3-1 1.8-1.6l34.6-31.2c1.8-1.4 3-3.6 3-6.1 0-4.2-3.5-7.7-7.8-7.7z" fill="url(#d)" transform="translate(-.023)"/><path fill="#000" d="M34.6 37.4h51v51h-51z"/><path fill="#FFF" d="M39 78.8h19.1V82H39zm-.2-28l1.5-1.4c.4.5.8.8 1.3.8.6 0 .9-.4.9-1.2v-5.3h2.3V49c0 1-.3 1.8-.8 2.3-.5.5-1.3.8-2.3.8-1.5.1-2.3-.5-2.9-1.3zm6.5-7H52v1.9h-4.4V47h4v1.8h-4v1.3h4.5v2h-6.7zm9.7 2h-2.5v-2h7.3v2h-2.5v6.3H55zM39 54h4.3c1 0 1.8.3 2.3.7.3.3.5.8.5 1.4 0 1-.5 1.5-1.3 1.9 1 .3 1.6.9 1.6 2 0 1.4-1.2 2.3-3.1 2.3H39V54zm4.8 2.6c0-.5-.4-.7-1-.7h-1.5v1.5h1.4c.7-.1 1.1-.3 1.1-.8zM43 59h-1.8v1.5H43c.7 0 1.1-.3 1.1-.8s-.4-.7-1.1-.7zm3.8-5h3.9c1.3 0 2.1.3 2.7.9.5.5.7 1.1.7 1.9 0 1.3-.7 2.1-1.7 2.6l2 2.9h-2.6l-1.7-2.5h-1v2.5h-2.3V54zm3.8 4c.8 0 1.2-.4 1.2-1 0-.7-.5-1-1.2-1h-1.5v2h1.5z"/><path d="M56.8 54H59l3.5 8.4H60l-.6-1.5h-3.2l-.6 1.5h-2.4l3.6-8.4zm2 5l-.9-2.3L57 59h1.8zm4-5h2.3v8.3h-2.3zm2.9 0h2.1l3.4 4.4V54h2.3v8.3h-2L68 57.8v4.6h-2.3zm8 7.1l1.3-1.5c.8.7 1.7 1 2.7 1 .6 0 1-.2 1-.6 0-.4-.3-.5-1.4-.8-1.8-.4-3.1-.9-3.1-2.6 0-1.5 1.2-2.7 3.2-2.7 1.4 0 2.5.4 3.4 1.1l-1.2 1.6c-.8-.5-1.6-.8-2.3-.8-.6 0-.8.2-.8.5 0 .4.3.5 1.4.8 1.9.4 3.1 1 3.1 2.6 0 1.7-1.3 2.7-3.4 2.7-1.5.1-2.9-.4-3.9-1.3z" fill="#FFF"/></g></svg>
|
After Width: | Height: | Size: 3.0 KiB |
10
test/test-plugin/public/index.html
Normal file
10
test/test-plugin/public/index.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Test Plugin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Welcome to the test plugin!</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
39
test/test-plugin/src/index.ts
Normal file
39
test/test-plugin/src/index.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import * as express from "express"
|
||||||
|
import * as fspath from "path"
|
||||||
|
import * as pluginapi from "../../../typings/pluginapi"
|
||||||
|
|
||||||
|
export const plugin: pluginapi.Plugin = {
|
||||||
|
displayName: "Test Plugin",
|
||||||
|
routerPath: "/test-plugin",
|
||||||
|
homepageURL: "https://example.com",
|
||||||
|
description: "Plugin used in code-server tests.",
|
||||||
|
|
||||||
|
init(config) {
|
||||||
|
config.logger.debug("test-plugin loaded!")
|
||||||
|
},
|
||||||
|
|
||||||
|
router() {
|
||||||
|
const r = express.Router()
|
||||||
|
r.get("/test-app", (req, res) => {
|
||||||
|
res.sendFile(fspath.resolve(__dirname, "../public/index.html"))
|
||||||
|
})
|
||||||
|
r.get("/goland/icon.svg", (req, res) => {
|
||||||
|
res.sendFile(fspath.resolve(__dirname, "../public/icon.svg"))
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
},
|
||||||
|
|
||||||
|
applications() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "Test App",
|
||||||
|
version: "4.0.0",
|
||||||
|
iconPath: "/icon.svg",
|
||||||
|
path: "/test-app",
|
||||||
|
|
||||||
|
description: "This app does XYZ.",
|
||||||
|
homepageURL: "https://example.com",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
69
test/test-plugin/tsconfig.json
Normal file
69
test/test-plugin/tsconfig.json
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||||
|
|
||||||
|
/* Basic Options */
|
||||||
|
// "incremental": true, /* Enable incremental compilation */
|
||||||
|
"target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
|
||||||
|
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
|
||||||
|
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||||
|
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||||
|
// "checkJs": true, /* Report errors in .js files. */
|
||||||
|
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||||
|
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||||
|
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||||
|
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||||
|
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||||
|
"outDir": "./out" /* Redirect output structure to the directory. */,
|
||||||
|
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||||
|
// "composite": true, /* Enable project compilation */
|
||||||
|
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||||
|
// "removeComments": true, /* Do not emit comments to output. */
|
||||||
|
// "noEmit": true, /* Do not emit outputs. */
|
||||||
|
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||||
|
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||||
|
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||||
|
|
||||||
|
/* Strict Type-Checking Options */
|
||||||
|
"strict": true /* Enable all strict type-checking options. */,
|
||||||
|
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||||
|
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||||
|
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||||
|
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||||
|
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||||
|
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||||
|
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||||
|
|
||||||
|
/* Additional Checks */
|
||||||
|
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||||
|
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||||
|
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||||
|
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||||
|
|
||||||
|
/* Module Resolution Options */
|
||||||
|
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||||
|
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||||
|
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||||
|
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||||
|
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||||
|
// "types": [], /* Type declaration files to be included in compilation. */
|
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||||
|
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||||
|
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
|
||||||
|
/* Source Map Options */
|
||||||
|
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||||
|
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||||
|
|
||||||
|
/* Experimental Options */
|
||||||
|
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||||
|
|
||||||
|
/* Advanced Options */
|
||||||
|
"skipLibCheck": true /* Skip type checking of declaration files. */,
|
||||||
|
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||||
|
}
|
||||||
|
}
|
435
test/test-plugin/yarn.lock
Normal file
435
test/test-plugin/yarn.lock
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@types/body-parser@*":
|
||||||
|
version "1.19.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
|
||||||
|
integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/connect" "*"
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/connect@*":
|
||||||
|
version "3.4.33"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546"
|
||||||
|
integrity sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/express-serve-static-core@*":
|
||||||
|
version "4.17.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.13.tgz#d9af025e925fc8b089be37423b8d1eac781be084"
|
||||||
|
integrity sha512-RgDi5a4nuzam073lRGKTUIaL3eF2+H7LJvJ8eUnCI0wA6SNjXc44DCmWNiTLs/AZ7QlsFWZiw/gTG3nSQGL0fA==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
"@types/qs" "*"
|
||||||
|
"@types/range-parser" "*"
|
||||||
|
|
||||||
|
"@types/express@^4.17.8":
|
||||||
|
version "4.17.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.8.tgz#3df4293293317e61c60137d273a2e96cd8d5f27a"
|
||||||
|
integrity sha512-wLhcKh3PMlyA2cNAB9sjM1BntnhPMiM0JOBwPBqttjHev2428MLEB4AYVN+d8s2iyCVZac+o41Pflm/ZH5vLXQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/body-parser" "*"
|
||||||
|
"@types/express-serve-static-core" "*"
|
||||||
|
"@types/qs" "*"
|
||||||
|
"@types/serve-static" "*"
|
||||||
|
|
||||||
|
"@types/mime@*":
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"
|
||||||
|
integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==
|
||||||
|
|
||||||
|
"@types/node@*":
|
||||||
|
version "14.14.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.6.tgz#146d3da57b3c636cc0d1769396ce1cfa8991147f"
|
||||||
|
integrity sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==
|
||||||
|
|
||||||
|
"@types/qs@*":
|
||||||
|
version "6.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.5.tgz#434711bdd49eb5ee69d90c1d67c354a9a8ecb18b"
|
||||||
|
integrity sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ==
|
||||||
|
|
||||||
|
"@types/range-parser@*":
|
||||||
|
version "1.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
|
||||||
|
integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
|
||||||
|
|
||||||
|
"@types/serve-static@*":
|
||||||
|
version "1.13.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.6.tgz#866b1b8dec41c36e28c7be40ac725b88be43c5c1"
|
||||||
|
integrity sha512-nuRJmv7jW7VmCVTn+IgYDkkbbDGyIINOeu/G0d74X3lm6E5KfMeQPJhxIt1ayQeQB3cSxvYs1RA/wipYoFB4EA==
|
||||||
|
dependencies:
|
||||||
|
"@types/mime" "*"
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
|
accepts@~1.3.7:
|
||||||
|
version "1.3.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
|
||||||
|
integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
|
||||||
|
dependencies:
|
||||||
|
mime-types "~2.1.24"
|
||||||
|
negotiator "0.6.2"
|
||||||
|
|
||||||
|
array-flatten@1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
|
||||||
|
integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
|
||||||
|
|
||||||
|
body-parser@1.19.0:
|
||||||
|
version "1.19.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
|
||||||
|
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
|
||||||
|
dependencies:
|
||||||
|
bytes "3.1.0"
|
||||||
|
content-type "~1.0.4"
|
||||||
|
debug "2.6.9"
|
||||||
|
depd "~1.1.2"
|
||||||
|
http-errors "1.7.2"
|
||||||
|
iconv-lite "0.4.24"
|
||||||
|
on-finished "~2.3.0"
|
||||||
|
qs "6.7.0"
|
||||||
|
raw-body "2.4.0"
|
||||||
|
type-is "~1.6.17"
|
||||||
|
|
||||||
|
bytes@3.1.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
|
||||||
|
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
|
||||||
|
|
||||||
|
content-disposition@0.5.3:
|
||||||
|
version "0.5.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
|
||||||
|
integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
|
||||||
|
dependencies:
|
||||||
|
safe-buffer "5.1.2"
|
||||||
|
|
||||||
|
content-type@~1.0.4:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
|
||||||
|
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
|
||||||
|
|
||||||
|
cookie-signature@1.0.6:
|
||||||
|
version "1.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
||||||
|
integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
|
||||||
|
|
||||||
|
cookie@0.4.0:
|
||||||
|
version "0.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
|
||||||
|
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
|
||||||
|
|
||||||
|
debug@2.6.9:
|
||||||
|
version "2.6.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||||
|
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
|
||||||
|
dependencies:
|
||||||
|
ms "2.0.0"
|
||||||
|
|
||||||
|
depd@~1.1.2:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
|
||||||
|
integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
|
||||||
|
|
||||||
|
destroy@~1.0.4:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
|
||||||
|
integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
|
||||||
|
|
||||||
|
ee-first@1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||||
|
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
|
||||||
|
|
||||||
|
encodeurl@~1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
|
||||||
|
integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
|
||||||
|
|
||||||
|
escape-html@~1.0.3:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||||
|
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
|
||||||
|
|
||||||
|
etag@~1.8.1:
|
||||||
|
version "1.8.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
||||||
|
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
|
||||||
|
|
||||||
|
express@^4.17.1:
|
||||||
|
version "4.17.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
|
||||||
|
integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
|
||||||
|
dependencies:
|
||||||
|
accepts "~1.3.7"
|
||||||
|
array-flatten "1.1.1"
|
||||||
|
body-parser "1.19.0"
|
||||||
|
content-disposition "0.5.3"
|
||||||
|
content-type "~1.0.4"
|
||||||
|
cookie "0.4.0"
|
||||||
|
cookie-signature "1.0.6"
|
||||||
|
debug "2.6.9"
|
||||||
|
depd "~1.1.2"
|
||||||
|
encodeurl "~1.0.2"
|
||||||
|
escape-html "~1.0.3"
|
||||||
|
etag "~1.8.1"
|
||||||
|
finalhandler "~1.1.2"
|
||||||
|
fresh "0.5.2"
|
||||||
|
merge-descriptors "1.0.1"
|
||||||
|
methods "~1.1.2"
|
||||||
|
on-finished "~2.3.0"
|
||||||
|
parseurl "~1.3.3"
|
||||||
|
path-to-regexp "0.1.7"
|
||||||
|
proxy-addr "~2.0.5"
|
||||||
|
qs "6.7.0"
|
||||||
|
range-parser "~1.2.1"
|
||||||
|
safe-buffer "5.1.2"
|
||||||
|
send "0.17.1"
|
||||||
|
serve-static "1.14.1"
|
||||||
|
setprototypeof "1.1.1"
|
||||||
|
statuses "~1.5.0"
|
||||||
|
type-is "~1.6.18"
|
||||||
|
utils-merge "1.0.1"
|
||||||
|
vary "~1.1.2"
|
||||||
|
|
||||||
|
finalhandler@~1.1.2:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
|
||||||
|
integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
|
||||||
|
dependencies:
|
||||||
|
debug "2.6.9"
|
||||||
|
encodeurl "~1.0.2"
|
||||||
|
escape-html "~1.0.3"
|
||||||
|
on-finished "~2.3.0"
|
||||||
|
parseurl "~1.3.3"
|
||||||
|
statuses "~1.5.0"
|
||||||
|
unpipe "~1.0.0"
|
||||||
|
|
||||||
|
forwarded@~0.1.2:
|
||||||
|
version "0.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
|
||||||
|
integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
|
||||||
|
|
||||||
|
fresh@0.5.2:
|
||||||
|
version "0.5.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
|
||||||
|
integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
|
||||||
|
|
||||||
|
http-errors@1.7.2:
|
||||||
|
version "1.7.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
|
||||||
|
integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
|
||||||
|
dependencies:
|
||||||
|
depd "~1.1.2"
|
||||||
|
inherits "2.0.3"
|
||||||
|
setprototypeof "1.1.1"
|
||||||
|
statuses ">= 1.5.0 < 2"
|
||||||
|
toidentifier "1.0.0"
|
||||||
|
|
||||||
|
http-errors@~1.7.2:
|
||||||
|
version "1.7.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
|
||||||
|
integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
|
||||||
|
dependencies:
|
||||||
|
depd "~1.1.2"
|
||||||
|
inherits "2.0.4"
|
||||||
|
setprototypeof "1.1.1"
|
||||||
|
statuses ">= 1.5.0 < 2"
|
||||||
|
toidentifier "1.0.0"
|
||||||
|
|
||||||
|
iconv-lite@0.4.24:
|
||||||
|
version "0.4.24"
|
||||||
|
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
||||||
|
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
|
||||||
|
dependencies:
|
||||||
|
safer-buffer ">= 2.1.2 < 3"
|
||||||
|
|
||||||
|
inherits@2.0.3:
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
|
||||||
|
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
|
||||||
|
|
||||||
|
inherits@2.0.4:
|
||||||
|
version "2.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
ipaddr.js@1.9.1:
|
||||||
|
version "1.9.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
||||||
|
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
|
||||||
|
|
||||||
|
media-typer@0.3.0:
|
||||||
|
version "0.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
||||||
|
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
|
||||||
|
|
||||||
|
merge-descriptors@1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
|
||||||
|
integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
|
||||||
|
|
||||||
|
methods@~1.1.2:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
|
||||||
|
integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
|
||||||
|
|
||||||
|
mime-db@1.44.0:
|
||||||
|
version "1.44.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92"
|
||||||
|
integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==
|
||||||
|
|
||||||
|
mime-types@~2.1.24:
|
||||||
|
version "2.1.27"
|
||||||
|
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f"
|
||||||
|
integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==
|
||||||
|
dependencies:
|
||||||
|
mime-db "1.44.0"
|
||||||
|
|
||||||
|
mime@1.6.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
|
||||||
|
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
|
||||||
|
|
||||||
|
ms@2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||||
|
integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
|
||||||
|
|
||||||
|
ms@2.1.1:
|
||||||
|
version "2.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
|
||||||
|
integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
|
||||||
|
|
||||||
|
negotiator@0.6.2:
|
||||||
|
version "0.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
|
||||||
|
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
|
||||||
|
|
||||||
|
on-finished@~2.3.0:
|
||||||
|
version "2.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
|
||||||
|
integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
|
||||||
|
dependencies:
|
||||||
|
ee-first "1.1.1"
|
||||||
|
|
||||||
|
parseurl@~1.3.3:
|
||||||
|
version "1.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||||
|
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||||
|
|
||||||
|
path-to-regexp@0.1.7:
|
||||||
|
version "0.1.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
|
||||||
|
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
|
||||||
|
|
||||||
|
proxy-addr@~2.0.5:
|
||||||
|
version "2.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf"
|
||||||
|
integrity sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==
|
||||||
|
dependencies:
|
||||||
|
forwarded "~0.1.2"
|
||||||
|
ipaddr.js "1.9.1"
|
||||||
|
|
||||||
|
qs@6.7.0:
|
||||||
|
version "6.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
|
||||||
|
integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
|
||||||
|
|
||||||
|
range-parser@~1.2.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
|
||||||
|
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
|
||||||
|
|
||||||
|
raw-body@2.4.0:
|
||||||
|
version "2.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
|
||||||
|
integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
|
||||||
|
dependencies:
|
||||||
|
bytes "3.1.0"
|
||||||
|
http-errors "1.7.2"
|
||||||
|
iconv-lite "0.4.24"
|
||||||
|
unpipe "1.0.0"
|
||||||
|
|
||||||
|
safe-buffer@5.1.2:
|
||||||
|
version "5.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
|
||||||
|
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||||
|
|
||||||
|
"safer-buffer@>= 2.1.2 < 3":
|
||||||
|
version "2.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||||
|
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||||
|
|
||||||
|
send@0.17.1:
|
||||||
|
version "0.17.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
|
||||||
|
integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
|
||||||
|
dependencies:
|
||||||
|
debug "2.6.9"
|
||||||
|
depd "~1.1.2"
|
||||||
|
destroy "~1.0.4"
|
||||||
|
encodeurl "~1.0.2"
|
||||||
|
escape-html "~1.0.3"
|
||||||
|
etag "~1.8.1"
|
||||||
|
fresh "0.5.2"
|
||||||
|
http-errors "~1.7.2"
|
||||||
|
mime "1.6.0"
|
||||||
|
ms "2.1.1"
|
||||||
|
on-finished "~2.3.0"
|
||||||
|
range-parser "~1.2.1"
|
||||||
|
statuses "~1.5.0"
|
||||||
|
|
||||||
|
serve-static@1.14.1:
|
||||||
|
version "1.14.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
|
||||||
|
integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
|
||||||
|
dependencies:
|
||||||
|
encodeurl "~1.0.2"
|
||||||
|
escape-html "~1.0.3"
|
||||||
|
parseurl "~1.3.3"
|
||||||
|
send "0.17.1"
|
||||||
|
|
||||||
|
setprototypeof@1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
|
||||||
|
integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
|
||||||
|
|
||||||
|
"statuses@>= 1.5.0 < 2", statuses@~1.5.0:
|
||||||
|
version "1.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
|
||||||
|
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
|
||||||
|
|
||||||
|
toidentifier@1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
|
||||||
|
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
||||||
|
|
||||||
|
type-is@~1.6.17, type-is@~1.6.18:
|
||||||
|
version "1.6.18"
|
||||||
|
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
|
||||||
|
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
|
||||||
|
dependencies:
|
||||||
|
media-typer "0.3.0"
|
||||||
|
mime-types "~2.1.24"
|
||||||
|
|
||||||
|
typescript@^4.0.5:
|
||||||
|
version "4.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389"
|
||||||
|
integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==
|
||||||
|
|
||||||
|
unpipe@1.0.0, unpipe@~1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
|
||||||
|
integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
|
||||||
|
|
||||||
|
utils-merge@1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||||
|
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
|
||||||
|
|
||||||
|
vary@~1.1.2:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||||
|
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
|
@ -2,13 +2,11 @@ import * as assert from "assert"
|
|||||||
import * as fs from "fs-extra"
|
import * as fs from "fs-extra"
|
||||||
import * as http from "http"
|
import * as http from "http"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { LatestResponse, UpdateHttpProvider } from "../src/node/app/update"
|
|
||||||
import { AuthType } from "../src/node/http"
|
|
||||||
import { SettingsProvider, UpdateSettings } from "../src/node/settings"
|
import { SettingsProvider, UpdateSettings } from "../src/node/settings"
|
||||||
|
import { LatestResponse, UpdateProvider } from "../src/node/update"
|
||||||
import { tmpdir } from "../src/node/util"
|
import { tmpdir } from "../src/node/util"
|
||||||
|
|
||||||
describe("update", () => {
|
describe.skip("update", () => {
|
||||||
return
|
|
||||||
let version = "1.0.0"
|
let version = "1.0.0"
|
||||||
let spy: string[] = []
|
let spy: string[] = []
|
||||||
const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => {
|
const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => {
|
||||||
@ -35,22 +33,14 @@ describe("update", () => {
|
|||||||
const jsonPath = path.join(tmpdir, "tests/updates/update.json")
|
const jsonPath = path.join(tmpdir, "tests/updates/update.json")
|
||||||
const settings = new SettingsProvider<UpdateSettings>(jsonPath)
|
const settings = new SettingsProvider<UpdateSettings>(jsonPath)
|
||||||
|
|
||||||
let _provider: UpdateHttpProvider | undefined
|
let _provider: UpdateProvider | undefined
|
||||||
const provider = (): UpdateHttpProvider => {
|
const provider = (): UpdateProvider => {
|
||||||
if (!_provider) {
|
if (!_provider) {
|
||||||
const address = server.address()
|
const address = server.address()
|
||||||
if (!address || typeof address === "string" || !address.port) {
|
if (!address || typeof address === "string" || !address.port) {
|
||||||
throw new Error("unexpected address")
|
throw new Error("unexpected address")
|
||||||
}
|
}
|
||||||
_provider = new UpdateHttpProvider(
|
_provider = new UpdateProvider(`http://${address.address}:${address.port}/latest`, settings)
|
||||||
{
|
|
||||||
auth: AuthType.None,
|
|
||||||
commit: "test",
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
`http://${address.address}:${address.port}/latest`,
|
|
||||||
settings,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return _provider
|
return _provider
|
||||||
}
|
}
|
||||||
@ -154,14 +144,10 @@ describe("update", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("should not reject if unable to fetch", async () => {
|
it("should not reject if unable to fetch", async () => {
|
||||||
const options = {
|
let provider = new UpdateProvider("invalid", settings)
|
||||||
auth: AuthType.None,
|
|
||||||
commit: "test",
|
|
||||||
}
|
|
||||||
let provider = new UpdateHttpProvider(options, true, "invalid", settings)
|
|
||||||
await assert.doesNotReject(() => provider.getUpdate(true))
|
await assert.doesNotReject(() => provider.getUpdate(true))
|
||||||
|
|
||||||
provider = new UpdateHttpProvider(options, true, "http://probably.invalid.dev.localhost/latest", settings)
|
provider = new UpdateProvider("http://probably.invalid.dev.localhost/latest", settings)
|
||||||
await assert.doesNotReject(() => provider.getUpdate(true))
|
await assert.doesNotReject(() => provider.getUpdate(true))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -16,7 +16,8 @@
|
|||||||
"tsBuildInfoFile": "./.cache/tsbuildinfo",
|
"tsBuildInfoFile": "./.cache/tsbuildinfo",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"typeRoots": ["./node_modules/@types", "./typings"]
|
"typeRoots": ["./node_modules/@types", "./typings"],
|
||||||
|
"downlevelIteration": true
|
||||||
},
|
},
|
||||||
"include": ["./src/**/*.ts"]
|
"include": ["./src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
189
typings/pluginapi.d.ts
vendored
Normal file
189
typings/pluginapi.d.ts
vendored
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* This file describes the code-server plugin API for adding new applications.
|
||||||
|
*/
|
||||||
|
import { Logger } from "@coder/logger"
|
||||||
|
import * as express from "express"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay
|
||||||
|
*
|
||||||
|
* The homepage of code-server will launch into VS Code. However, there will be an overlay
|
||||||
|
* button that when clicked, will show all available applications with their names,
|
||||||
|
* icons and provider plugins. When one clicks on an app's icon, they will be directed
|
||||||
|
* to <code-server-root>/<plugin-path>/<app-path> to access the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugins
|
||||||
|
*
|
||||||
|
* Plugins are just node modules that contain a top level export "plugin" that implements
|
||||||
|
* the Plugin interface.
|
||||||
|
*
|
||||||
|
* 1. code-server uses $CS_PLUGIN to find plugins.
|
||||||
|
*
|
||||||
|
* e.g. CS_PLUGIN=/tmp/will:/tmp/teffen will cause code-server to load
|
||||||
|
* /tmp/will and /tmp/teffen as plugins.
|
||||||
|
*
|
||||||
|
* 2. code-server uses $CS_PLUGIN_PATH to find plugins. Each subdirectory in
|
||||||
|
* $CS_PLUGIN_PATH with a package.json where the engine is code-server is
|
||||||
|
* a valid plugin.
|
||||||
|
*
|
||||||
|
* e.g. CS_PLUGIN_PATH=/tmp/nhooyr:/tmp/ash will cause code-server to search
|
||||||
|
* /tmp/nhooyr and then /tmp/ash for plugins.
|
||||||
|
*
|
||||||
|
* CS_PLUGIN_PATH defaults to
|
||||||
|
* ~/.local/share/code-server/plugins:/usr/share/code-server/plugins
|
||||||
|
* if unset.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* 3. Built in plugins are loaded from __dirname/../plugins
|
||||||
|
*
|
||||||
|
* Plugins are required as soon as they are found and then initialized.
|
||||||
|
* See the Plugin interface for details.
|
||||||
|
*
|
||||||
|
* If two plugins are found with the exact same name, then code-server will
|
||||||
|
* use the first one and emit a warning.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmability
|
||||||
|
*
|
||||||
|
* There is also a /api/applications endpoint to allow programmatic access to all
|
||||||
|
* available applications. It could be used to create a custom application dashboard
|
||||||
|
* for example. An important difference with the API is that all application paths
|
||||||
|
* will be absolute (i.e have the plugin path prepended) so that they may be used
|
||||||
|
* directly.
|
||||||
|
*
|
||||||
|
* Example output:
|
||||||
|
*
|
||||||
|
* [
|
||||||
|
* {
|
||||||
|
* "name": "Test App",
|
||||||
|
* "version": "4.0.0",
|
||||||
|
* "iconPath": "/test-plugin/test-app/icon.svg",
|
||||||
|
* "path": "/test-plugin/test-app",
|
||||||
|
* "description": "This app does XYZ.",
|
||||||
|
* "homepageURL": "https://example.com",
|
||||||
|
* "plugin": {
|
||||||
|
* "name": "test-plugin",
|
||||||
|
* "version": "1.0.0",
|
||||||
|
* "modulePath": "/Users/nhooyr/src/cdr/code-server/test/test-plugin",
|
||||||
|
* "displayName": "Test Plugin",
|
||||||
|
* "description": "Plugin used in code-server tests.",
|
||||||
|
* "routerPath": "/test-plugin",
|
||||||
|
* "homepageURL": "https://example.com"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Your plugin module must have a top level export "plugin" that implements this interface.
|
||||||
|
*
|
||||||
|
* The plugin's router will be mounted at <code-sever-root>/<plugin-path>
|
||||||
|
*/
|
||||||
|
export interface Plugin {
|
||||||
|
/**
|
||||||
|
* name is used as the plugin's unique identifier.
|
||||||
|
* No two plugins may share the same name.
|
||||||
|
*
|
||||||
|
* Fetched from package.json.
|
||||||
|
*/
|
||||||
|
readonly name?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version for the plugin in the overlay.
|
||||||
|
*
|
||||||
|
* Fetched from package.json.
|
||||||
|
*/
|
||||||
|
readonly version?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name used in the overlay.
|
||||||
|
*/
|
||||||
|
readonly displayName: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used in overlay.
|
||||||
|
* Should be a full sentence describing the plugin.
|
||||||
|
*/
|
||||||
|
readonly description: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path at which the plugin router is to be registered.
|
||||||
|
*/
|
||||||
|
readonly routerPath: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to plugin homepage.
|
||||||
|
*/
|
||||||
|
readonly homepageURL: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* init is called so that the plugin may initialize itself with the config.
|
||||||
|
*/
|
||||||
|
init(config: PluginConfig): void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the plugin's router.
|
||||||
|
*
|
||||||
|
* Mounted at <code-sever-root>/<plugin-path>
|
||||||
|
*
|
||||||
|
* If not present, the plugin provides no routes.
|
||||||
|
*/
|
||||||
|
router?(): express.Router
|
||||||
|
|
||||||
|
/**
|
||||||
|
* code-server uses this to collect the list of applications that
|
||||||
|
* the plugin can currently provide.
|
||||||
|
* It is called when /api/applications is hit or the overlay needs to
|
||||||
|
* refresh the list of applications
|
||||||
|
*
|
||||||
|
* Ensure this is as fast as possible.
|
||||||
|
*
|
||||||
|
* If not present, the plugin provides no applications.
|
||||||
|
*/
|
||||||
|
applications?(): Application[] | Promise<Application[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginConfig contains the configuration required for initializing
|
||||||
|
* a plugin.
|
||||||
|
*/
|
||||||
|
export interface PluginConfig {
|
||||||
|
/**
|
||||||
|
* All plugin logs should be logged via this logger.
|
||||||
|
*/
|
||||||
|
readonly logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application represents a user accessible application.
|
||||||
|
*/
|
||||||
|
export interface Application {
|
||||||
|
readonly name: string
|
||||||
|
readonly version: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user clicks on the icon in the overlay, they will be
|
||||||
|
* redirected to <code-server-root>/<plugin-path>/<app-path>
|
||||||
|
* where the application should be accessible.
|
||||||
|
*
|
||||||
|
* If undefined, then <code-server-root>/<plugin-path> is used.
|
||||||
|
*/
|
||||||
|
readonly path?: string
|
||||||
|
|
||||||
|
readonly description?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path at which the icon for this application can be accessed.
|
||||||
|
* <code-server-root>/<plugin-path>/<app-path>/<icon-path>
|
||||||
|
*/
|
||||||
|
readonly iconPath: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to application homepage.
|
||||||
|
*/
|
||||||
|
readonly homepageURL: string
|
||||||
|
}
|
Reference in New Issue
Block a user