Implement new structure
This commit is contained in:
159
src/node/api/server.ts
Normal file
159
src/node/api/server.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { field, logger } from "@coder/logger"
|
||||
import * as http from "http"
|
||||
import * as net from "net"
|
||||
import * as querystring from "querystring"
|
||||
import * as ws from "ws"
|
||||
import { ApplicationsResponse, ClientMessage, FilesResponse, LoginResponse, ServerMessage } from "../../common/api"
|
||||
import { ApiEndpoint, HttpCode } from "../../common/http"
|
||||
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, PostData } from "../http"
|
||||
import { hash } from "../util"
|
||||
|
||||
interface LoginPayload extends PostData {
|
||||
password?: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* API HTTP provider.
|
||||
*/
|
||||
export class ApiHttpProvider extends HttpProvider {
|
||||
private readonly ws = new ws.Server({ noServer: true })
|
||||
|
||||
public constructor(private readonly server: HttpServer, options: HttpProviderOptions) {
|
||||
super(options)
|
||||
}
|
||||
|
||||
public async handleRequest(
|
||||
base: string,
|
||||
_requestPath: string,
|
||||
_query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage
|
||||
): Promise<HttpResponse | undefined> {
|
||||
switch (base) {
|
||||
case ApiEndpoint.login:
|
||||
if (request.method === "POST") {
|
||||
return this.login(request)
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (!this.authenticated(request)) {
|
||||
return { code: HttpCode.Unauthorized }
|
||||
}
|
||||
switch (base) {
|
||||
case ApiEndpoint.applications:
|
||||
return this.applications()
|
||||
case ApiEndpoint.files:
|
||||
return this.files()
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
public async handleWebSocket(
|
||||
_base: string,
|
||||
_requestPath: string,
|
||||
_query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage,
|
||||
socket: net.Socket,
|
||||
head: Buffer
|
||||
): Promise<true> {
|
||||
if (!this.authenticated(request)) {
|
||||
throw new Error("not authenticated")
|
||||
}
|
||||
await new Promise<ws>((resolve) => {
|
||||
this.ws.handleUpgrade(request, socket, head, (ws) => {
|
||||
const send = (event: ServerMessage): void => {
|
||||
ws.send(JSON.stringify(event))
|
||||
}
|
||||
ws.on("message", (data) => {
|
||||
logger.trace("got message", field("message", data))
|
||||
try {
|
||||
const message: ClientMessage = JSON.parse(data.toString())
|
||||
this.getMessageResponse(message.event).then(send)
|
||||
} catch (error) {
|
||||
logger.error(error.message, field("message", data))
|
||||
}
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private async getMessageResponse(event: "health"): Promise<ServerMessage> {
|
||||
switch (event) {
|
||||
case "health":
|
||||
return { event, connections: await this.server.getConnections() }
|
||||
default:
|
||||
throw new Error("unexpected message")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return OK and a cookie if the user is authenticated otherwise return
|
||||
* unauthorized.
|
||||
*/
|
||||
private async login(request: http.IncomingMessage): Promise<HttpResponse<LoginResponse>> {
|
||||
const ok = (password: string | true): HttpResponse<LoginResponse> => {
|
||||
return {
|
||||
content: {
|
||||
success: true,
|
||||
},
|
||||
cookie: typeof password === "string" ? { key: "key", value: password } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Already authenticated via cookies?
|
||||
const providedPassword = this.authenticated(request)
|
||||
if (providedPassword) {
|
||||
return ok(providedPassword)
|
||||
}
|
||||
|
||||
const data = await this.getData(request)
|
||||
const payload: LoginPayload = data ? querystring.parse(data) : {}
|
||||
const password = this.authenticated(request, {
|
||||
key: typeof payload.password === "string" ? [hash(payload.password)] : undefined,
|
||||
})
|
||||
if (password) {
|
||||
return ok(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),
|
||||
})
|
||||
)
|
||||
|
||||
return { code: HttpCode.Unauthorized }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return files at the requested directory.
|
||||
*/
|
||||
private async files(): Promise<HttpResponse<FilesResponse>> {
|
||||
return {
|
||||
content: {
|
||||
files: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return available applications.
|
||||
*/
|
||||
private async applications(): Promise<HttpResponse<ApplicationsResponse>> {
|
||||
return {
|
||||
content: {
|
||||
applications: [
|
||||
{
|
||||
name: "VS Code",
|
||||
path: "/vscode",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
37
src/node/app/server.tsx
Normal file
37
src/node/app/server.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import * as React from "react"
|
||||
import * as ReactDOMServer from "react-dom/server"
|
||||
import * as ReactRouterDOM from "react-router-dom"
|
||||
import App from "../../browser/app"
|
||||
import { HttpProvider, HttpResponse } from "../http"
|
||||
|
||||
/**
|
||||
* Top-level and fallback HTTP provider.
|
||||
*/
|
||||
export class MainHttpProvider extends HttpProvider {
|
||||
public async handleRequest(base: string, requestPath: string): Promise<HttpResponse | undefined> {
|
||||
if (base === "/static") {
|
||||
const response = await this.getResource(this.rootPath, requestPath)
|
||||
response.cache = true
|
||||
return response
|
||||
}
|
||||
|
||||
const response = await this.getUtf8Resource(this.rootPath, "src/browser/index.html")
|
||||
response.content = response.content
|
||||
.replace(/{{COMMIT}}/g, "") // TODO
|
||||
.replace(/"{{OPTIONS}}"/g, `'${JSON.stringify({ logLevel: logger.level })}'`)
|
||||
.replace(
|
||||
/{{COMPONENT}}/g,
|
||||
ReactDOMServer.renderToString(
|
||||
<ReactRouterDOM.StaticRouter location={base}>
|
||||
<App />
|
||||
</ReactRouterDOM.StaticRouter>
|
||||
)
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
public async handleWebSocket(): Promise<undefined> {
|
||||
return undefined
|
||||
}
|
||||
}
|
@ -1,343 +0,0 @@
|
||||
import * as path from "path";
|
||||
import { VSBuffer, VSBufferReadableStream } from "vs/base/common/buffer";
|
||||
import { Emitter, Event } from "vs/base/common/event";
|
||||
import { IDisposable } from "vs/base/common/lifecycle";
|
||||
import { OS } from "vs/base/common/platform";
|
||||
import { ReadableStreamEventPayload } from "vs/base/common/stream";
|
||||
import { URI, UriComponents } from "vs/base/common/uri";
|
||||
import { transformOutgoingURIs } from "vs/base/common/uriIpc";
|
||||
import { IServerChannel } from "vs/base/parts/ipc/common/ipc";
|
||||
import { IDiagnosticInfo } from "vs/platform/diagnostics/common/diagnostics";
|
||||
import { IEnvironmentService } from "vs/platform/environment/common/environment";
|
||||
import { ExtensionIdentifier, IExtensionDescription } from "vs/platform/extensions/common/extensions";
|
||||
import { FileDeleteOptions, FileOpenOptions, FileOverwriteOptions, FileReadStreamOptions, FileType, FileWriteOptions, IStat, IWatchOptions } from "vs/platform/files/common/files";
|
||||
import { createReadStream } from "vs/platform/files/common/io";
|
||||
import { DiskFileSystemProvider } from "vs/platform/files/node/diskFileSystemProvider";
|
||||
import { ILogService } from "vs/platform/log/common/log";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { IRemoteAgentEnvironment, RemoteAgentConnectionContext } from "vs/platform/remote/common/remoteAgentEnvironment";
|
||||
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
|
||||
import { INodeProxyService } from "vs/server/src/common/nodeProxy";
|
||||
import { getTranslations } from "vs/server/src/node/nls";
|
||||
import { getUriTransformer, localRequire } from "vs/server/src/node/util";
|
||||
import { IFileChangeDto } from "vs/workbench/api/common/extHost.protocol";
|
||||
import { ExtensionScanner, ExtensionScannerInput } from "vs/workbench/services/extensions/node/extensionPoints";
|
||||
|
||||
/**
|
||||
* Extend the file provider to allow unwatching.
|
||||
*/
|
||||
class Watcher extends DiskFileSystemProvider {
|
||||
public readonly watches = new Map<number, IDisposable>();
|
||||
|
||||
public dispose(): void {
|
||||
this.watches.forEach((w) => w.dispose());
|
||||
this.watches.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public _watch(req: number, resource: URI, opts: IWatchOptions): void {
|
||||
this.watches.set(req, this.watch(resource, opts));
|
||||
}
|
||||
|
||||
public unwatch(req: number): void {
|
||||
this.watches.get(req)!.dispose();
|
||||
this.watches.delete(req);
|
||||
}
|
||||
}
|
||||
|
||||
export class FileProviderChannel implements IServerChannel<RemoteAgentConnectionContext>, IDisposable {
|
||||
private readonly provider: DiskFileSystemProvider;
|
||||
private readonly watchers = new Map<string, Watcher>();
|
||||
|
||||
public constructor(
|
||||
private readonly environmentService: IEnvironmentService,
|
||||
private readonly logService: ILogService,
|
||||
) {
|
||||
this.provider = new DiskFileSystemProvider(this.logService);
|
||||
}
|
||||
|
||||
public listen(context: RemoteAgentConnectionContext, event: string, args?: any): Event<any> {
|
||||
switch (event) {
|
||||
case "filechange": return this.filechange(context, args[0]);
|
||||
case "readFileStream": return this.readFileStream(args[0], args[1]);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid listen "${event}"`);
|
||||
}
|
||||
|
||||
private filechange(context: RemoteAgentConnectionContext, session: string): Event<IFileChangeDto[]> {
|
||||
const emitter = new Emitter<IFileChangeDto[]>({
|
||||
onFirstListenerAdd: () => {
|
||||
const provider = new Watcher(this.logService);
|
||||
this.watchers.set(session, provider);
|
||||
const transformer = getUriTransformer(context.remoteAuthority);
|
||||
provider.onDidChangeFile((events) => {
|
||||
emitter.fire(events.map((event) => ({
|
||||
...event,
|
||||
resource: transformer.transformOutgoing(event.resource),
|
||||
})));
|
||||
});
|
||||
provider.onDidErrorOccur((event) => this.logService.error(event));
|
||||
},
|
||||
onLastListenerRemove: () => {
|
||||
this.watchers.get(session)!.dispose();
|
||||
this.watchers.delete(session);
|
||||
},
|
||||
});
|
||||
|
||||
return emitter.event;
|
||||
}
|
||||
|
||||
private readFileStream(resource: UriComponents, opts: FileReadStreamOptions): Event<ReadableStreamEventPayload<VSBuffer>> {
|
||||
let fileStream: VSBufferReadableStream | undefined;
|
||||
const emitter = new Emitter<ReadableStreamEventPayload<VSBuffer>>({
|
||||
onFirstListenerAdd: () => {
|
||||
if (!fileStream) {
|
||||
fileStream = createReadStream(this.provider, this.transform(resource), {
|
||||
...opts,
|
||||
bufferSize: 64 * 1024, // From DiskFileSystemProvider
|
||||
});
|
||||
fileStream.on("data", (data) => emitter.fire(data));
|
||||
fileStream.on("error", (error) => emitter.fire(error));
|
||||
fileStream.on("end", () => emitter.fire("end"));
|
||||
}
|
||||
},
|
||||
onLastListenerRemove: () => fileStream && fileStream.destroy(),
|
||||
});
|
||||
|
||||
return emitter.event;
|
||||
}
|
||||
|
||||
public call(_: unknown, command: string, args?: any): Promise<any> {
|
||||
switch (command) {
|
||||
case "stat": return this.stat(args[0]);
|
||||
case "open": return this.open(args[0], args[1]);
|
||||
case "close": return this.close(args[0]);
|
||||
case "read": return this.read(args[0], args[1], args[2]);
|
||||
case "readFile": return this.readFile(args[0]);
|
||||
case "write": return this.write(args[0], args[1], args[2], args[3], args[4]);
|
||||
case "writeFile": return this.writeFile(args[0], args[1], args[2]);
|
||||
case "delete": return this.delete(args[0], args[1]);
|
||||
case "mkdir": return this.mkdir(args[0]);
|
||||
case "readdir": return this.readdir(args[0]);
|
||||
case "rename": return this.rename(args[0], args[1], args[2]);
|
||||
case "copy": return this.copy(args[0], args[1], args[2]);
|
||||
case "watch": return this.watch(args[0], args[1], args[2], args[3]);
|
||||
case "unwatch": return this.unwatch(args[0], args[1]);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid call "${command}"`);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.watchers.forEach((w) => w.dispose());
|
||||
this.watchers.clear();
|
||||
}
|
||||
|
||||
private async stat(resource: UriComponents): Promise<IStat> {
|
||||
return this.provider.stat(this.transform(resource));
|
||||
}
|
||||
|
||||
private async open(resource: UriComponents, opts: FileOpenOptions): Promise<number> {
|
||||
return this.provider.open(this.transform(resource), opts);
|
||||
}
|
||||
|
||||
private async close(fd: number): Promise<void> {
|
||||
return this.provider.close(fd);
|
||||
}
|
||||
|
||||
private async read(fd: number, pos: number, length: number): Promise<[VSBuffer, number]> {
|
||||
const buffer = VSBuffer.alloc(length);
|
||||
const bytesRead = await this.provider.read(fd, pos, buffer.buffer, 0, length);
|
||||
return [buffer, bytesRead];
|
||||
}
|
||||
|
||||
private async readFile(resource: UriComponents): Promise<VSBuffer> {
|
||||
return VSBuffer.wrap(await this.provider.readFile(this.transform(resource)));
|
||||
}
|
||||
|
||||
private write(fd: number, pos: number, buffer: VSBuffer, offset: number, length: number): Promise<number> {
|
||||
return this.provider.write(fd, pos, buffer.buffer, offset, length);
|
||||
}
|
||||
|
||||
private writeFile(resource: UriComponents, buffer: VSBuffer, opts: FileWriteOptions): Promise<void> {
|
||||
return this.provider.writeFile(this.transform(resource), buffer.buffer, opts);
|
||||
}
|
||||
|
||||
private async delete(resource: UriComponents, opts: FileDeleteOptions): Promise<void> {
|
||||
return this.provider.delete(this.transform(resource), opts);
|
||||
}
|
||||
|
||||
private async mkdir(resource: UriComponents): Promise<void> {
|
||||
return this.provider.mkdir(this.transform(resource));
|
||||
}
|
||||
|
||||
private async readdir(resource: UriComponents): Promise<[string, FileType][]> {
|
||||
return this.provider.readdir(this.transform(resource));
|
||||
}
|
||||
|
||||
private async rename(resource: UriComponents, target: UriComponents, opts: FileOverwriteOptions): Promise<void> {
|
||||
return this.provider.rename(this.transform(resource), URI.from(target), opts);
|
||||
}
|
||||
|
||||
private copy(resource: UriComponents, target: UriComponents, opts: FileOverwriteOptions): Promise<void> {
|
||||
return this.provider.copy(this.transform(resource), URI.from(target), opts);
|
||||
}
|
||||
|
||||
private async watch(session: string, req: number, resource: UriComponents, opts: IWatchOptions): Promise<void> {
|
||||
this.watchers.get(session)!._watch(req, this.transform(resource), opts);
|
||||
}
|
||||
|
||||
private async unwatch(session: string, req: number): Promise<void> {
|
||||
this.watchers.get(session)!.unwatch(req);
|
||||
}
|
||||
|
||||
private transform(resource: UriComponents): URI {
|
||||
// Used for walkthrough content.
|
||||
if (/^\/static[^/]*\//.test(resource.path)) {
|
||||
return URI.file(this.environmentService.appRoot + resource.path.replace(/^\/static[^/]*\//, "/"));
|
||||
// Used by the webview service worker to load resources.
|
||||
} else if (resource.path === "/vscode-resource" && resource.query) {
|
||||
try {
|
||||
const query = JSON.parse(resource.query);
|
||||
if (query.requestResourcePath) {
|
||||
return URI.file(query.requestResourcePath);
|
||||
}
|
||||
} catch (error) { /* Carry on. */ }
|
||||
}
|
||||
return URI.from(resource);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExtensionEnvironmentChannel implements IServerChannel {
|
||||
public constructor(
|
||||
private readonly environment: IEnvironmentService,
|
||||
private readonly log: ILogService,
|
||||
private readonly telemetry: ITelemetryService,
|
||||
private readonly connectionToken: string,
|
||||
) {}
|
||||
|
||||
public listen(_: unknown, event: string): Event<any> {
|
||||
throw new Error(`Invalid listen "${event}"`);
|
||||
}
|
||||
|
||||
public async call(context: any, command: string, args?: any): Promise<any> {
|
||||
switch (command) {
|
||||
case "getEnvironmentData":
|
||||
return transformOutgoingURIs(
|
||||
await this.getEnvironmentData(args.language),
|
||||
getUriTransformer(context.remoteAuthority),
|
||||
);
|
||||
case "getDiagnosticInfo": return this.getDiagnosticInfo();
|
||||
case "disableTelemetry": return this.disableTelemetry();
|
||||
}
|
||||
throw new Error(`Invalid call "${command}"`);
|
||||
}
|
||||
|
||||
private async getEnvironmentData(locale: string): Promise<IRemoteAgentEnvironment> {
|
||||
return {
|
||||
pid: process.pid,
|
||||
connectionToken: this.connectionToken,
|
||||
appRoot: URI.file(this.environment.appRoot),
|
||||
appSettingsHome: this.environment.appSettingsHome,
|
||||
settingsPath: this.environment.machineSettingsHome,
|
||||
logsPath: URI.file(this.environment.logsPath),
|
||||
extensionsPath: URI.file(this.environment.extensionsPath!),
|
||||
extensionHostLogsPath: URI.file(path.join(this.environment.logsPath, "extension-host")),
|
||||
globalStorageHome: URI.file(this.environment.globalStorageHome),
|
||||
userHome: URI.file(this.environment.userHome),
|
||||
extensions: await this.scanExtensions(locale),
|
||||
os: OS,
|
||||
};
|
||||
}
|
||||
|
||||
private async scanExtensions(locale: string): Promise<IExtensionDescription[]> {
|
||||
const translations = await getTranslations(locale, this.environment.userDataPath);
|
||||
|
||||
const scanMultiple = (isBuiltin: boolean, isUnderDevelopment: boolean, paths: string[]): Promise<IExtensionDescription[][]> => {
|
||||
return Promise.all(paths.map((path) => {
|
||||
return ExtensionScanner.scanExtensions(new ExtensionScannerInput(
|
||||
product.version,
|
||||
product.commit,
|
||||
locale,
|
||||
!!process.env.VSCODE_DEV,
|
||||
path,
|
||||
isBuiltin,
|
||||
isUnderDevelopment,
|
||||
translations,
|
||||
), this.log);
|
||||
}));
|
||||
};
|
||||
|
||||
const scanBuiltin = async (): Promise<IExtensionDescription[][]> => {
|
||||
return scanMultiple(true, false, [this.environment.builtinExtensionsPath, ...this.environment.extraBuiltinExtensionPaths]);
|
||||
};
|
||||
|
||||
const scanInstalled = async (): Promise<IExtensionDescription[][]> => {
|
||||
return scanMultiple(false, true, [this.environment.extensionsPath!, ...this.environment.extraExtensionPaths]);
|
||||
};
|
||||
|
||||
return Promise.all([scanBuiltin(), scanInstalled()]).then((allExtensions) => {
|
||||
const uniqueExtensions = new Map<string, IExtensionDescription>();
|
||||
allExtensions.forEach((multipleExtensions) => {
|
||||
multipleExtensions.forEach((extensions) => {
|
||||
extensions.forEach((extension) => {
|
||||
const id = ExtensionIdentifier.toKey(extension.identifier);
|
||||
if (uniqueExtensions.has(id)) {
|
||||
const oldPath = uniqueExtensions.get(id)!.extensionLocation.fsPath;
|
||||
const newPath = extension.extensionLocation.fsPath;
|
||||
this.log.warn(`${oldPath} has been overridden ${newPath}`);
|
||||
}
|
||||
uniqueExtensions.set(id, extension);
|
||||
});
|
||||
});
|
||||
});
|
||||
return Array.from(uniqueExtensions.values());
|
||||
});
|
||||
}
|
||||
|
||||
private getDiagnosticInfo(): Promise<IDiagnosticInfo> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
private async disableTelemetry(): Promise<void> {
|
||||
this.telemetry.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeProxyService implements INodeProxyService {
|
||||
public _serviceBrand = undefined;
|
||||
|
||||
public readonly server: import("@coder/node-browser/out/server/server").Server;
|
||||
|
||||
private readonly _onMessage = new Emitter<string>();
|
||||
public readonly onMessage = this._onMessage.event;
|
||||
private readonly _$onMessage = new Emitter<string>();
|
||||
public readonly $onMessage = this._$onMessage.event;
|
||||
public readonly _onDown = new Emitter<void>();
|
||||
public readonly onDown = this._onDown.event;
|
||||
public readonly _onUp = new Emitter<void>();
|
||||
public readonly onUp = this._onUp.event;
|
||||
|
||||
// Unused because the server connection will never permanently close.
|
||||
private readonly _onClose = new Emitter<void>();
|
||||
public readonly onClose = this._onClose.event;
|
||||
|
||||
public constructor() {
|
||||
// TODO: down/up
|
||||
const { Server } = localRequire<typeof import("@coder/node-browser/out/server/server")>("@coder/node-browser/out/server/server");
|
||||
this.server = new Server({
|
||||
onMessage: this.$onMessage,
|
||||
onClose: this.onClose,
|
||||
onDown: this.onDown,
|
||||
onUp: this.onUp,
|
||||
send: (message: string): void => {
|
||||
this._onMessage.fire(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public send(message: string): void {
|
||||
this._$onMessage.fire(message);
|
||||
}
|
||||
}
|
299
src/node/cli.ts
299
src/node/cli.ts
@ -1,299 +0,0 @@
|
||||
import * as cp from "child_process";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { setUnexpectedErrorHandler } from "vs/base/common/errors";
|
||||
import { main as vsCli } from "vs/code/node/cliProcessMain";
|
||||
import { validatePaths } from "vs/code/node/paths";
|
||||
import { ParsedArgs } from "vs/platform/environment/common/environment";
|
||||
import { buildHelpMessage, buildVersionMessage, Option as VsOption, OPTIONS, OptionDescriptions } from "vs/platform/environment/node/argv";
|
||||
import { parseMainProcessArgv } from "vs/platform/environment/node/argvHelper";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { ipcMain } from "vs/server/src/node/ipc";
|
||||
import { enableCustomMarketplace } from "vs/server/src/node/marketplace";
|
||||
import { MainServer } from "vs/server/src/node/server";
|
||||
import { AuthType, buildAllowedMessage, enumToArray, FormatType, generateCertificate, generatePassword, localRequire, open, unpackExecutables } from "vs/server/src/node/util";
|
||||
|
||||
const { logger } = localRequire<typeof import("@coder/logger/out/index")>("@coder/logger/out/index");
|
||||
setUnexpectedErrorHandler((error) => logger.warn(error.message));
|
||||
|
||||
interface Args extends ParsedArgs {
|
||||
auth?: AuthType;
|
||||
"base-path"?: string;
|
||||
cert?: string;
|
||||
"cert-key"?: string;
|
||||
format?: string;
|
||||
host?: string;
|
||||
open?: boolean;
|
||||
port?: string;
|
||||
socket?: string;
|
||||
}
|
||||
|
||||
// @ts-ignore: Force `keyof Args` to work.
|
||||
interface Option extends VsOption {
|
||||
id: keyof Args;
|
||||
}
|
||||
|
||||
const getArgs = (): Args => {
|
||||
// Remove options that won't work or don't make sense.
|
||||
for (let key in OPTIONS) {
|
||||
switch (key) {
|
||||
case "add":
|
||||
case "diff":
|
||||
case "file-uri":
|
||||
case "folder-uri":
|
||||
case "goto":
|
||||
case "new-window":
|
||||
case "reuse-window":
|
||||
case "wait":
|
||||
case "disable-gpu":
|
||||
// TODO: pretty sure these don't work but not 100%.
|
||||
case "prof-startup":
|
||||
case "inspect-extensions":
|
||||
case "inspect-brk-extensions":
|
||||
delete OPTIONS[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const options = OPTIONS as OptionDescriptions<Required<Args>>;
|
||||
options["base-path"] = { type: "string", cat: "o", description: "Base path of the URL at which code-server is hosted (used for login redirects)." };
|
||||
options["cert"] = { type: "string", cat: "o", description: "Path to certificate. If the path is omitted, both this and --cert-key will be generated." };
|
||||
options["cert-key"] = { type: "string", cat: "o", description: "Path to the certificate's key if one was provided." };
|
||||
options["format"] = { type: "string", cat: "o", description: `Format for the version. ${buildAllowedMessage(FormatType)}.` };
|
||||
options["host"] = { type: "string", cat: "o", description: "Host for the server." };
|
||||
options["auth"] = { type: "string", cat: "o", description: `The type of authentication to use. ${buildAllowedMessage(AuthType)}.` };
|
||||
options["open"] = { type: "boolean", cat: "o", description: "Open in the browser on startup." };
|
||||
options["port"] = { type: "string", cat: "o", description: "Port for the main server." };
|
||||
options["socket"] = { type: "string", cat: "o", description: "Listen on a socket instead of host:port." };
|
||||
|
||||
const args = parseMainProcessArgv(process.argv);
|
||||
if (!args["user-data-dir"]) {
|
||||
args["user-data-dir"] = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share"), "code-server");
|
||||
}
|
||||
if (!args["extensions-dir"]) {
|
||||
args["extensions-dir"] = path.join(args["user-data-dir"], "extensions");
|
||||
}
|
||||
|
||||
if (!args.verbose && !args.log && process.env.LOG_LEVEL) {
|
||||
args.log = process.env.LOG_LEVEL;
|
||||
}
|
||||
|
||||
return validatePaths(args);
|
||||
};
|
||||
|
||||
const startVscode = async (args: Args): Promise<void | void[]> => {
|
||||
const extra = args["_"] || [];
|
||||
const options = {
|
||||
auth: args.auth || AuthType.Password,
|
||||
basePath: args["base-path"],
|
||||
cert: args.cert,
|
||||
certKey: args["cert-key"],
|
||||
openUri: extra.length > 1 ? extra[extra.length - 1] : undefined,
|
||||
host: args.host,
|
||||
password: process.env.PASSWORD,
|
||||
};
|
||||
|
||||
if (enumToArray(AuthType).filter((t) => t === options.auth).length === 0) {
|
||||
throw new Error(`'${options.auth}' is not a valid authentication type.`);
|
||||
} else if (options.auth === "password" && !options.password) {
|
||||
options.password = await generatePassword();
|
||||
}
|
||||
|
||||
if (!options.certKey && typeof options.certKey !== "undefined") {
|
||||
throw new Error(`--cert-key cannot be blank`);
|
||||
} else if (options.certKey && !options.cert) {
|
||||
throw new Error(`--cert-key was provided but --cert was not`);
|
||||
} if (!options.cert && typeof options.cert !== "undefined") {
|
||||
const { cert, certKey } = await generateCertificate();
|
||||
options.cert = cert;
|
||||
options.certKey = certKey;
|
||||
}
|
||||
|
||||
enableCustomMarketplace();
|
||||
|
||||
const server = new MainServer({
|
||||
...options,
|
||||
port: typeof args.port !== "undefined" ? parseInt(args.port, 10) : 8080,
|
||||
socket: args.socket,
|
||||
}, args);
|
||||
|
||||
const [serverAddress, /* ignore */] = await Promise.all([
|
||||
server.listen(),
|
||||
unpackExecutables(),
|
||||
]);
|
||||
logger.info(`Server listening on ${serverAddress}`);
|
||||
|
||||
if (options.auth === "password" && !process.env.PASSWORD) {
|
||||
logger.info(` - Password is ${options.password}`);
|
||||
logger.info(" - To use your own password, set the PASSWORD environment variable");
|
||||
if (!args.auth) {
|
||||
logger.info(" - To disable use `--auth none`");
|
||||
}
|
||||
} else if (options.auth === "password") {
|
||||
logger.info(" - Using custom password for authentication");
|
||||
} else {
|
||||
logger.info(" - No authentication");
|
||||
}
|
||||
|
||||
if (server.protocol === "https") {
|
||||
logger.info(
|
||||
args.cert
|
||||
? ` - Using provided certificate${args["cert-key"] ? " and key" : ""} for HTTPS`
|
||||
: ` - Using generated certificate and key for HTTPS`,
|
||||
);
|
||||
} else {
|
||||
logger.info(" - Not serving HTTPS");
|
||||
}
|
||||
|
||||
if (!server.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(console.error);
|
||||
logger.info(` - Opened ${openAddress}`);
|
||||
}
|
||||
};
|
||||
|
||||
const startCli = (args: Args): boolean | Promise<void> => {
|
||||
if (args.help) {
|
||||
const executable = `${product.applicationName}${os.platform() === "win32" ? ".exe" : ""}`;
|
||||
console.log(buildHelpMessage(product.nameLong, executable, product.codeServerVersion, OPTIONS, false));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.version) {
|
||||
if (args.format === "json") {
|
||||
console.log(JSON.stringify({
|
||||
codeServerVersion: product.codeServerVersion,
|
||||
commit: product.commit,
|
||||
vscodeVersion: product.version,
|
||||
}));
|
||||
} else {
|
||||
buildVersionMessage(product.codeServerVersion, product.commit).split("\n").map((line) => logger.info(line));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const shouldSpawnCliProcess = (): boolean => {
|
||||
return !!args["install-source"]
|
||||
|| !!args["list-extensions"]
|
||||
|| !!args["install-extension"]
|
||||
|| !!args["uninstall-extension"]
|
||||
|| !!args["locate-extension"]
|
||||
|| !!args["telemetry"];
|
||||
};
|
||||
|
||||
if (shouldSpawnCliProcess()) {
|
||||
enableCustomMarketplace();
|
||||
return vsCli(args);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export class WrapperProcess {
|
||||
private process?: cp.ChildProcess;
|
||||
private started?: Promise<void>;
|
||||
private currentVersion = product.codeServerVersion;
|
||||
|
||||
public constructor(private readonly args: Args) {
|
||||
ipcMain.onMessage(async (message) => {
|
||||
switch (message.type) {
|
||||
case "relaunch":
|
||||
logger.info(`Relaunching: ${this.currentVersion} -> ${message.version}`);
|
||||
this.currentVersion = message.version;
|
||||
this.started = undefined;
|
||||
if (this.process) {
|
||||
this.process.removeAllListeners();
|
||||
this.process.kill();
|
||||
}
|
||||
try {
|
||||
await this.start();
|
||||
} catch (error) {
|
||||
logger.error(error.message);
|
||||
process.exit(typeof error.code === "number" ? error.code : 1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
logger.error(`Unrecognized message ${message}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public start(): Promise<void> {
|
||||
if (!this.started) {
|
||||
const child = this.spawn();
|
||||
this.started = ipcMain.handshake(child).then(() => {
|
||||
child.once("exit", (code) => exit(code!));
|
||||
});
|
||||
this.process = child;
|
||||
}
|
||||
return this.started;
|
||||
}
|
||||
|
||||
private spawn(): cp.ChildProcess {
|
||||
// Flags to pass along to the Node binary. We use the environment variable
|
||||
// since otherwise the code-server binary will swallow them.
|
||||
const maxMemory = this.args["max-memory"] || 2048;
|
||||
let nodeOptions = `${process.env.NODE_OPTIONS || ""} ${this.args["js-flags"] || ""}`;
|
||||
if (!/max_old_space_size=(\d+)/g.exec(nodeOptions)) {
|
||||
nodeOptions += ` --max_old_space_size=${maxMemory}`;
|
||||
}
|
||||
|
||||
// If we're using loose files then we need to specify the path. If we're in
|
||||
// the binary we need to let the binary determine the path (via nbin) since
|
||||
// it could be different between binaries which presents a problem when
|
||||
// upgrading (different version numbers or different staging directories).
|
||||
const isBinary = (global as any).NBIN_LOADED;
|
||||
return cp.spawn(process.argv[0], process.argv.slice(isBinary ? 2 : 1), {
|
||||
env: {
|
||||
...process.env,
|
||||
LAUNCH_VSCODE: "true",
|
||||
NBIN_BYPASS: undefined,
|
||||
VSCODE_PARENT_PID: process.pid.toString(),
|
||||
NODE_OPTIONS: nodeOptions,
|
||||
},
|
||||
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const main = async(): Promise<boolean | void | void[]> => {
|
||||
const args = getArgs();
|
||||
if (process.env.LAUNCH_VSCODE) {
|
||||
await ipcMain.handshake();
|
||||
return startVscode(args);
|
||||
}
|
||||
return startCli(args) || new WrapperProcess(args).start();
|
||||
};
|
||||
|
||||
const exit = process.exit;
|
||||
process.exit = function (code?: number) {
|
||||
const err = new Error(`process.exit() was prevented: ${code || "unknown code"}.`);
|
||||
console.warn(err.stack);
|
||||
} as (code?: number) => never;
|
||||
|
||||
// Copy the extension host behavior of killing oneself if the parent dies. This
|
||||
// also exists in bootstrap-fork.js but spawning with that won't work because we
|
||||
// override process.exit.
|
||||
if (typeof process.env.VSCODE_PARENT_PID !== "undefined") {
|
||||
const parentPid = parseInt(process.env.VSCODE_PARENT_PID, 10);
|
||||
setInterval(() => {
|
||||
try {
|
||||
process.kill(parentPid, 0); // Throws an exception if the process doesn't exist anymore.
|
||||
} catch (e) {
|
||||
exit();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// It's possible that the pipe has closed (for example if you run code-server
|
||||
// --version | head -1). Assume that means we're done.
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.on("error", () => exit());
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error(error.message);
|
||||
exit(typeof error.code === "number" ? error.code : 1);
|
||||
});
|
@ -1,156 +0,0 @@
|
||||
import * as cp from "child_process";
|
||||
import { getPathFromAmdModule } from "vs/base/common/amd";
|
||||
import { VSBuffer } from "vs/base/common/buffer";
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
import { ISocket } from "vs/base/parts/ipc/common/ipc.net";
|
||||
import { NodeSocket } from "vs/base/parts/ipc/node/ipc.net";
|
||||
import { IEnvironmentService } from "vs/platform/environment/common/environment";
|
||||
import { ILogService } from "vs/platform/log/common/log";
|
||||
import { getNlsConfiguration } from "vs/server/src/node/nls";
|
||||
import { Protocol } from "vs/server/src/node/protocol";
|
||||
import { uriTransformerPath } from "vs/server/src/node/util";
|
||||
import { IExtHostReadyMessage } from "vs/workbench/services/extensions/common/extensionHostProtocol";
|
||||
|
||||
export abstract class Connection {
|
||||
private readonly _onClose = new Emitter<void>();
|
||||
public readonly onClose = this._onClose.event;
|
||||
private disposed = false;
|
||||
private _offline: number | undefined;
|
||||
|
||||
public constructor(protected protocol: Protocol, public readonly token: string) {}
|
||||
|
||||
public get offline(): number | undefined {
|
||||
return this._offline;
|
||||
}
|
||||
|
||||
public reconnect(socket: ISocket, buffer: VSBuffer): void {
|
||||
this._offline = undefined;
|
||||
this.doReconnect(socket, buffer);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (!this.disposed) {
|
||||
this.disposed = true;
|
||||
this.doDispose();
|
||||
this._onClose.fire();
|
||||
}
|
||||
}
|
||||
|
||||
protected setOffline(): void {
|
||||
if (!this._offline) {
|
||||
this._offline = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the connection on a new socket.
|
||||
*/
|
||||
protected abstract doReconnect(socket: ISocket, buffer: VSBuffer): void;
|
||||
protected abstract doDispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for all the IPC channels.
|
||||
*/
|
||||
export class ManagementConnection extends Connection {
|
||||
public constructor(protected protocol: Protocol, token: string) {
|
||||
super(protocol, token);
|
||||
protocol.onClose(() => this.dispose()); // Explicit close.
|
||||
protocol.onSocketClose(() => this.setOffline()); // Might reconnect.
|
||||
}
|
||||
|
||||
protected doDispose(): void {
|
||||
this.protocol.sendDisconnect();
|
||||
this.protocol.dispose();
|
||||
this.protocol.getSocket().end();
|
||||
}
|
||||
|
||||
protected doReconnect(socket: ISocket, buffer: VSBuffer): void {
|
||||
this.protocol.beginAcceptReconnection(socket, buffer);
|
||||
this.protocol.endAcceptReconnection();
|
||||
}
|
||||
}
|
||||
|
||||
export class ExtensionHostConnection extends Connection {
|
||||
private process?: cp.ChildProcess;
|
||||
|
||||
public constructor(
|
||||
locale:string, protocol: Protocol, buffer: VSBuffer, token: string,
|
||||
private readonly log: ILogService,
|
||||
private readonly environment: IEnvironmentService,
|
||||
) {
|
||||
super(protocol, token);
|
||||
this.protocol.dispose();
|
||||
this.spawn(locale, buffer).then((p) => this.process = p);
|
||||
this.protocol.getUnderlyingSocket().pause();
|
||||
}
|
||||
|
||||
protected doDispose(): void {
|
||||
if (this.process) {
|
||||
this.process.kill();
|
||||
}
|
||||
this.protocol.getSocket().end();
|
||||
}
|
||||
|
||||
protected doReconnect(socket: ISocket, buffer: VSBuffer): void {
|
||||
// This is just to set the new socket.
|
||||
this.protocol.beginAcceptReconnection(socket, null);
|
||||
this.protocol.dispose();
|
||||
this.sendInitMessage(buffer);
|
||||
}
|
||||
|
||||
private sendInitMessage(buffer: VSBuffer): void {
|
||||
const socket = this.protocol.getUnderlyingSocket();
|
||||
socket.pause();
|
||||
this.process!.send({ // Process must be set at this point.
|
||||
type: "VSCODE_EXTHOST_IPC_SOCKET",
|
||||
initialDataChunk: (buffer.buffer as Buffer).toString("base64"),
|
||||
skipWebSocketFrames: this.protocol.getSocket() instanceof NodeSocket,
|
||||
}, socket);
|
||||
}
|
||||
|
||||
private async spawn(locale: string, buffer: VSBuffer): Promise<cp.ChildProcess> {
|
||||
const config = await getNlsConfiguration(locale, this.environment.userDataPath);
|
||||
const proc = cp.fork(
|
||||
getPathFromAmdModule(require, "bootstrap-fork"),
|
||||
[ "--type=extensionHost", `--uriTransformerPath=${uriTransformerPath}` ],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
AMD_ENTRYPOINT: "vs/workbench/services/extensions/node/extensionHostProcess",
|
||||
PIPE_LOGGING: "true",
|
||||
VERBOSE_LOGGING: "true",
|
||||
VSCODE_EXTHOST_WILL_SEND_SOCKET: "true",
|
||||
VSCODE_HANDLES_UNCAUGHT_ERRORS: "true",
|
||||
VSCODE_LOG_STACK: "false",
|
||||
VSCODE_LOG_LEVEL: this.environment.verbose ? "trace" : this.environment.log,
|
||||
VSCODE_NLS_CONFIG: JSON.stringify(config),
|
||||
},
|
||||
silent: true,
|
||||
},
|
||||
);
|
||||
|
||||
proc.on("error", () => this.dispose());
|
||||
proc.on("exit", () => this.dispose());
|
||||
proc.stdout.setEncoding("utf8").on("data", (d) => this.log.info("Extension host stdout", d));
|
||||
proc.stderr.setEncoding("utf8").on("data", (d) => this.log.error("Extension host stderr", d));
|
||||
proc.on("message", (event) => {
|
||||
if (event && event.type === "__$console") {
|
||||
const severity = (<any>this.log)[event.severity] ? event.severity : "info";
|
||||
(<any>this.log)[severity]("Extension host", event.arguments);
|
||||
}
|
||||
if (event && event.type === "VSCODE_EXTHOST_DISCONNECTED") {
|
||||
this.setOffline();
|
||||
}
|
||||
});
|
||||
|
||||
const listen = (message: IExtHostReadyMessage) => {
|
||||
if (message.type === "VSCODE_EXTHOST_IPC_READY") {
|
||||
proc.removeListener("message", listen);
|
||||
this.sendInitMessage(buffer);
|
||||
}
|
||||
};
|
||||
|
||||
return proc.on("message", listen);
|
||||
}
|
||||
}
|
87
src/node/entry.ts
Normal file
87
src/node/entry.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import { ApiHttpProvider } from "./api/server"
|
||||
import { MainHttpProvider } from "./app/server"
|
||||
import { AuthType, HttpServer } from "./http"
|
||||
import { generateCertificate, generatePassword, hash, open } from "./util"
|
||||
import { VscodeHttpProvider } from "./vscode/server"
|
||||
import { ipcMain, wrap } from "./wrapper"
|
||||
|
||||
export interface Args {
|
||||
auth?: AuthType
|
||||
"base-path"?: string
|
||||
cert?: string
|
||||
"cert-key"?: string
|
||||
format?: string
|
||||
host?: string
|
||||
open?: boolean
|
||||
port?: string
|
||||
socket?: string
|
||||
_?: string[]
|
||||
}
|
||||
|
||||
const main = async (args: Args = {}): Promise<void> => {
|
||||
// Spawn the main HTTP server.
|
||||
const options = {
|
||||
basePath: args["base-path"],
|
||||
cert: args.cert,
|
||||
certKey: args["cert-key"],
|
||||
host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"),
|
||||
port: typeof args.port !== "undefined" ? parseInt(args.port, 10) : 8080,
|
||||
socket: args.socket,
|
||||
}
|
||||
if (!options.cert && typeof options.cert !== "undefined") {
|
||||
const { cert, certKey } = await generateCertificate()
|
||||
options.cert = cert
|
||||
options.certKey = certKey
|
||||
}
|
||||
const httpServer = new HttpServer(options)
|
||||
|
||||
// Register all the providers.
|
||||
// TODO: Might be cleaner to be able to register with just the class name
|
||||
// then let HttpServer instantiate with the common arguments.
|
||||
const auth = args.auth || AuthType.Password
|
||||
const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword()))
|
||||
const password = originalPassword && hash(originalPassword)
|
||||
httpServer.registerHttpProvider("/", new MainHttpProvider({ base: "/", auth, password }))
|
||||
httpServer.registerHttpProvider("/api", new ApiHttpProvider(httpServer, { base: "/", auth, password }))
|
||||
httpServer.registerHttpProvider(
|
||||
"/vscode-embed",
|
||||
new VscodeHttpProvider([], { base: "/vscode-embed", auth, password })
|
||||
)
|
||||
|
||||
ipcMain.onDispose(() => httpServer.dispose())
|
||||
|
||||
const serverAddress = await httpServer.listen()
|
||||
logger.info(`Server listening on ${serverAddress}`)
|
||||
|
||||
if (auth === AuthType.Password && !process.env.PASSWORD) {
|
||||
logger.info(` - Password is ${originalPassword}`)
|
||||
logger.info(" - To use your own password, set the PASSWORD environment variable")
|
||||
if (!args.auth) {
|
||||
logger.info(" - To disable use `--auth none`")
|
||||
}
|
||||
} else if (auth === AuthType.Password) {
|
||||
logger.info(" - Using custom password for authentication")
|
||||
} else {
|
||||
logger.info(" - No authentication")
|
||||
}
|
||||
|
||||
if (httpServer.protocol === "https") {
|
||||
logger.info(
|
||||
args.cert
|
||||
? ` - Using provided certificate${args["cert-key"] ? " and key" : ""} for HTTPS`
|
||||
: ` - Using generated certificate and key for HTTPS`
|
||||
)
|
||||
} else {
|
||||
logger.info(" - Not serving HTTPS")
|
||||
}
|
||||
|
||||
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(console.error)
|
||||
logger.info(` - Opened ${openAddress}`)
|
||||
}
|
||||
}
|
||||
|
||||
wrap(main)
|
579
src/node/http.ts
Normal file
579
src/node/http.ts
Normal file
@ -0,0 +1,579 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import * as fs from "fs-extra"
|
||||
import * as http from "http"
|
||||
import * as httpolyglot from "httpolyglot"
|
||||
import * as https from "https"
|
||||
import * as net from "net"
|
||||
import * as path from "path"
|
||||
import * as querystring from "querystring"
|
||||
import safeCompare from "safe-compare"
|
||||
import { Readable } from "stream"
|
||||
import * as tarFs from "tar-fs"
|
||||
import * as tls from "tls"
|
||||
import * as url from "url"
|
||||
import { HttpCode, HttpError } from "../common/http"
|
||||
import { plural, split } from "../common/util"
|
||||
import { getMediaMime, normalize, xdgLocalDir } from "./util"
|
||||
|
||||
export type Cookies = { [key: string]: string[] | undefined }
|
||||
export type PostData = { [key: string]: string | string[] | undefined }
|
||||
|
||||
interface AuthPayload extends Cookies {
|
||||
key?: string[]
|
||||
}
|
||||
|
||||
export enum AuthType {
|
||||
Password = "password",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
export type Query = { [key: string]: string | string[] | undefined }
|
||||
|
||||
export interface HttpResponse<T = string | Buffer | object> {
|
||||
/*
|
||||
* Whether to set cache-control headers for this response.
|
||||
*/
|
||||
cache?: boolean
|
||||
/**
|
||||
* If the code cannot be determined automatically set it here. The
|
||||
* defaults are 302 for redirects and 200 for successful requests. For errors
|
||||
* you should throw an HttpError and include the code there. If you
|
||||
* use Error it will default to 404 for ENOENT and EISDIR and 500 otherwise.
|
||||
*/
|
||||
code?: number
|
||||
/**
|
||||
* Content to write in the response. Mutually exclusive with stream.
|
||||
*/
|
||||
content?: T
|
||||
/**
|
||||
* Cookie to write with the response.
|
||||
*/
|
||||
cookie?: { key: string; value: string }
|
||||
/**
|
||||
* Used to automatically determine the appropriate mime type.
|
||||
*/
|
||||
filePath?: string
|
||||
/**
|
||||
* Additional headers to include.
|
||||
*/
|
||||
headers?: http.OutgoingHttpHeaders
|
||||
/**
|
||||
* If the mime type cannot be determined automatically set it here.
|
||||
*/
|
||||
mime?: string
|
||||
/**
|
||||
* Redirect to this path. Will rewrite against the base path but NOT the
|
||||
* provider endpoint so you must include it. This allows redirecting outside
|
||||
* of your endpoint. Use `withBase()` to redirect within your endpoint.
|
||||
*/
|
||||
redirect?: string
|
||||
/**
|
||||
* Stream this to the response. Mutually exclusive with content.
|
||||
*/
|
||||
stream?: Readable
|
||||
/**
|
||||
* Query variables to add in addition to current ones when redirecting. Use
|
||||
* `undefined` to remove a query variable.
|
||||
*/
|
||||
query?: Query
|
||||
}
|
||||
|
||||
/**
|
||||
* Use when you need to run search and replace on a file's content before
|
||||
* sending it.
|
||||
*/
|
||||
export interface HttpStringFileResponse extends HttpResponse {
|
||||
content: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface HttpServerOptions {
|
||||
readonly basePath?: string
|
||||
readonly cert?: string
|
||||
readonly certKey?: string
|
||||
readonly host?: string
|
||||
readonly port?: number
|
||||
readonly socket?: string
|
||||
}
|
||||
|
||||
interface ProviderRoute {
|
||||
base: string
|
||||
requestPath: string
|
||||
query: querystring.ParsedUrlQuery
|
||||
provider: HttpProvider
|
||||
fullPath: string
|
||||
originalPath: string
|
||||
}
|
||||
|
||||
export interface HttpProviderOptions {
|
||||
readonly base: string
|
||||
readonly auth: AuthType
|
||||
readonly password: string | false
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides HTTP responses. This abstract class provides some helpers for
|
||||
* interpreting, creating, and authenticating responses.
|
||||
*/
|
||||
export abstract class HttpProvider {
|
||||
protected readonly rootPath = path.resolve(__dirname, "../..")
|
||||
|
||||
public constructor(private readonly options: HttpProviderOptions) {}
|
||||
|
||||
public dispose(): void {
|
||||
// No default behavior.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle web sockets on the registered endpoint.
|
||||
*/
|
||||
public abstract handleWebSocket(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage,
|
||||
socket: net.Socket,
|
||||
head: Buffer
|
||||
): Promise<true | undefined>
|
||||
|
||||
/**
|
||||
* Handle requests to the registered endpoint.
|
||||
*/
|
||||
public abstract handleRequest(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage
|
||||
): Promise<HttpResponse | undefined>
|
||||
|
||||
/**
|
||||
* Return the specified path with the base path prepended.
|
||||
*/
|
||||
protected withBase(path: string): string {
|
||||
return normalize(`${this.options.base}/${path}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file resource.
|
||||
* TODO: Would a stream be faster, at least for large files?
|
||||
*/
|
||||
protected async getResource(...parts: string[]): Promise<HttpResponse> {
|
||||
const filePath = path.join(...parts)
|
||||
return { content: await fs.readFile(filePath), filePath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file resource as a string.
|
||||
*/
|
||||
protected async getUtf8Resource(...parts: string[]): Promise<HttpStringFileResponse> {
|
||||
const filePath = path.join(...parts)
|
||||
return { content: await fs.readFile(filePath, "utf8"), filePath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tar up and stream a directory.
|
||||
*/
|
||||
protected async getTarredResource(...parts: string[]): Promise<HttpResponse> {
|
||||
const filePath = path.join(...parts)
|
||||
return { stream: tarFs.pack(filePath), filePath, mime: "application/tar", cache: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to error on anything that's not a GET.
|
||||
*/
|
||||
protected ensureGet(request: http.IncomingMessage): void {
|
||||
if (request.method !== "GET") {
|
||||
throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to error if not authorized.
|
||||
*/
|
||||
protected ensureAuthenticated(request: http.IncomingMessage): void {
|
||||
if (!this.authenticated(request)) {
|
||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the first query value or the default if there isn't one.
|
||||
*/
|
||||
protected queryOrDefault(value: string | string[] | undefined, def: string): string {
|
||||
if (Array.isArray(value)) {
|
||||
value = value[0]
|
||||
}
|
||||
return typeof value !== "undefined" ? value : def
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the provided password value if the payload contains the right
|
||||
* password otherwise return false. If no payload is specified use cookies.
|
||||
*/
|
||||
protected authenticated(request: http.IncomingMessage, payload?: AuthPayload): string | boolean {
|
||||
switch (this.options.auth) {
|
||||
case AuthType.None:
|
||||
return true
|
||||
case AuthType.Password:
|
||||
if (typeof payload === "undefined") {
|
||||
payload = this.parseCookies<AuthPayload>(request)
|
||||
}
|
||||
if (this.options.password && payload.key) {
|
||||
for (let i = 0; i < payload.key.length; ++i) {
|
||||
if (safeCompare(payload.key[i], this.options.password)) {
|
||||
return payload.key[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
throw new Error(`Unsupported auth type ${this.options.auth}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse POST data.
|
||||
*/
|
||||
protected getData(request: http.IncomingMessage): Promise<string | undefined> {
|
||||
return request.method === "POST" || request.method === "DELETE"
|
||||
? new Promise<string>((resolve, reject) => {
|
||||
let body = ""
|
||||
const onEnd = (): void => {
|
||||
off() // eslint-disable-line @typescript-eslint/no-use-before-define
|
||||
resolve(body || undefined)
|
||||
}
|
||||
const onError = (error: Error): void => {
|
||||
off() // eslint-disable-line @typescript-eslint/no-use-before-define
|
||||
reject(error)
|
||||
}
|
||||
const onData = (d: Buffer): void => {
|
||||
body += d
|
||||
if (body.length > 1e6) {
|
||||
onError(new HttpError("Payload is too large", HttpCode.LargePayload))
|
||||
request.connection.destroy()
|
||||
}
|
||||
}
|
||||
const off = (): void => {
|
||||
request.off("error", onError)
|
||||
request.off("data", onError)
|
||||
request.off("end", onEnd)
|
||||
}
|
||||
request.on("error", onError)
|
||||
request.on("data", onData)
|
||||
request.on("end", onEnd)
|
||||
})
|
||||
: Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cookies.
|
||||
*/
|
||||
protected parseCookies<T extends Cookies>(request: http.IncomingMessage): T {
|
||||
const cookies: { [key: string]: string[] } = {}
|
||||
if (request.headers.cookie) {
|
||||
request.headers.cookie.split(";").forEach((keyValue) => {
|
||||
const [key, value] = split(keyValue, "=")
|
||||
if (!cookies[key]) {
|
||||
cookies[key] = []
|
||||
}
|
||||
cookies[key].push(decodeURI(value))
|
||||
})
|
||||
}
|
||||
return cookies as T
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a heartbeat using a local file to indicate activity.
|
||||
*/
|
||||
export class Heart {
|
||||
private heartbeatTimer?: NodeJS.Timeout
|
||||
private heartbeatInterval = 60000
|
||||
private lastHeartbeat = 0
|
||||
|
||||
public constructor(private readonly heartbeatPath: string, private readonly isActive: () => Promise<boolean>) {}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const now = Date.now()
|
||||
if (now - this.lastHeartbeat >= this.heartbeatInterval) {
|
||||
logger.trace("heartbeat")
|
||||
fs.outputFile(this.heartbeatPath, "").catch((error) => {
|
||||
logger.warn(error.message)
|
||||
})
|
||||
this.lastHeartbeat = now
|
||||
if (typeof this.heartbeatTimer !== "undefined") {
|
||||
clearTimeout(this.heartbeatTimer)
|
||||
}
|
||||
this.heartbeatTimer = setTimeout(() => {
|
||||
this.isActive().then((active) => {
|
||||
if (active) {
|
||||
this.beat()
|
||||
}
|
||||
})
|
||||
}, this.heartbeatInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An HTTP server. Its main role is to route incoming HTTP requests to the
|
||||
* appropriate provider for that endpoint then write out the response. It also
|
||||
* covers some common use cases like redirects and caching.
|
||||
*/
|
||||
export class HttpServer {
|
||||
protected readonly server: http.Server | https.Server
|
||||
private listenPromise: Promise<string | null> | undefined
|
||||
public readonly protocol: "http" | "https"
|
||||
private readonly providers = new Map<string, HttpProvider>()
|
||||
private readonly options: HttpServerOptions
|
||||
private readonly heart: Heart
|
||||
|
||||
public constructor(options: HttpServerOptions) {
|
||||
this.heart = new Heart(path.join(xdgLocalDir, "heartbeat"), async () => {
|
||||
const connections = await this.getConnections()
|
||||
logger.trace(`${connections} active connection${plural(connections)}`)
|
||||
return connections !== 0
|
||||
})
|
||||
this.options = {
|
||||
...options,
|
||||
basePath: options.basePath ? options.basePath.replace(/\/+$/, "") : "",
|
||||
}
|
||||
this.protocol = this.options.cert ? "https" : "http"
|
||||
if (this.protocol === "https") {
|
||||
this.server = httpolyglot.createServer(
|
||||
{
|
||||
cert: this.options.cert && fs.readFileSync(this.options.cert),
|
||||
key: this.options.certKey && fs.readFileSync(this.options.certKey),
|
||||
},
|
||||
this.onRequest
|
||||
)
|
||||
} else {
|
||||
this.server = http.createServer(this.onRequest)
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.providers.forEach((p) => p.dispose())
|
||||
}
|
||||
|
||||
public async getConnections(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server.getConnections((error, count) => {
|
||||
return error ? reject(error) : resolve(count)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a provider for a top-level endpoint.
|
||||
*/
|
||||
public registerHttpProvider<T extends HttpProvider>(endpoint: string, provider: T): void {
|
||||
endpoint = endpoint.replace(/^\/+|\/+$/g, "")
|
||||
if (this.providers.has(`/${endpoint}`)) {
|
||||
throw new Error(`${endpoint} is already registered`)
|
||||
}
|
||||
if (/\//.test(endpoint)) {
|
||||
throw new Error(`Only top-level endpoints are supported (got ${endpoint})`)
|
||||
}
|
||||
this.providers.set(`/${endpoint}`, provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening on the specified port.
|
||||
*/
|
||||
public listen(): Promise<string | null> {
|
||||
if (!this.listenPromise) {
|
||||
this.listenPromise = new Promise((resolve, reject) => {
|
||||
this.server.on("error", reject)
|
||||
this.server.on("upgrade", this.onUpgrade)
|
||||
const onListen = (): void => resolve(this.address())
|
||||
if (this.options.socket) {
|
||||
this.server.listen(this.options.socket, onListen)
|
||||
} else {
|
||||
this.server.listen(this.options.port, this.options.host, onListen)
|
||||
}
|
||||
})
|
||||
}
|
||||
return this.listenPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* The *local* address of the server.
|
||||
*/
|
||||
public address(): string | null {
|
||||
const address = this.server.address()
|
||||
const endpoint =
|
||||
typeof address !== "string" && address !== null
|
||||
? (address.address === "::" ? "localhost" : address.address) + ":" + address.port
|
||||
: address
|
||||
return endpoint && `${this.protocol}://${endpoint}`
|
||||
}
|
||||
|
||||
private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => {
|
||||
try {
|
||||
this.heart.beat()
|
||||
const route = this.parseUrl(request)
|
||||
const payload =
|
||||
this.maybeRedirect(request, route) ||
|
||||
(await route.provider.handleRequest(route.base, route.requestPath, route.query, request))
|
||||
if (!payload) {
|
||||
throw new HttpError("Not found", HttpCode.NotFound)
|
||||
}
|
||||
const basePath = this.options.basePath || "/"
|
||||
response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, {
|
||||
"Content-Type": payload.mime || getMediaMime(payload.filePath),
|
||||
...(payload.redirect
|
||||
? {
|
||||
Location: this.constructRedirect(
|
||||
request.headers.host as string,
|
||||
route.fullPath,
|
||||
normalize(`${basePath}/${payload.redirect}`) + "/",
|
||||
{ ...route.query, ...(payload.query || {}) }
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(request.headers["service-worker"] ? { "Service-Worker-Allowed": basePath } : {}),
|
||||
...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}),
|
||||
...(payload.cookie
|
||||
? {
|
||||
"Set-Cookie": `${payload.cookie.key}=${payload.cookie.value}; Path=${basePath}; HttpOnly; SameSite=strict`,
|
||||
}
|
||||
: {}),
|
||||
...payload.headers,
|
||||
})
|
||||
if (payload.stream) {
|
||||
payload.stream.on("error", (error: NodeJS.ErrnoException) => {
|
||||
response.writeHead(error.code === "ENOENT" ? HttpCode.NotFound : HttpCode.ServerError)
|
||||
response.end(error.message)
|
||||
})
|
||||
payload.stream.pipe(response)
|
||||
} else if (typeof payload.content === "string" || payload.content instanceof Buffer) {
|
||||
response.end(payload.content)
|
||||
} else if (payload.content && typeof payload.content === "object") {
|
||||
response.end(JSON.stringify(payload.content))
|
||||
} else {
|
||||
response.end()
|
||||
}
|
||||
} catch (error) {
|
||||
let e = error
|
||||
if (error.code === "ENOENT" || error.code === "EISDIR") {
|
||||
e = new HttpError("Not found", HttpCode.NotFound)
|
||||
} else {
|
||||
logger.error(error.stack)
|
||||
}
|
||||
response.writeHead(typeof e.code === "number" ? e.code : HttpCode.ServerError)
|
||||
response.end(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return any necessary redirection before delegating to a provider.
|
||||
*/
|
||||
private maybeRedirect(request: http.IncomingMessage, route: ProviderRoute): HttpResponse | undefined {
|
||||
// Redirect to HTTPS.
|
||||
if (this.options.cert && !(request.connection as tls.TLSSocket).encrypted) {
|
||||
return { redirect: route.fullPath }
|
||||
}
|
||||
// Redirect indexes to a trailing slash so relative paths will operate
|
||||
// against the provider.
|
||||
if (route.requestPath === "/index.html" && !route.originalPath.endsWith("/")) {
|
||||
return { redirect: route.fullPath } // Redirect always includes a trailing slash.
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private onUpgrade = async (request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> => {
|
||||
try {
|
||||
this.heart.beat()
|
||||
socket.on("error", () => socket.destroy())
|
||||
|
||||
if (this.options.cert && !(socket as tls.TLSSocket).encrypted) {
|
||||
throw new HttpError("HTTP websocket", HttpCode.BadRequest)
|
||||
}
|
||||
|
||||
if (!request.headers.upgrade || request.headers.upgrade.toLowerCase() !== "websocket") {
|
||||
throw new HttpError("HTTP/1.1 400 Bad Request", HttpCode.BadRequest)
|
||||
}
|
||||
|
||||
const { base, requestPath, query, provider } = this.parseUrl(request)
|
||||
if (!provider) {
|
||||
throw new HttpError("Not found", HttpCode.NotFound)
|
||||
}
|
||||
|
||||
if (!(await provider.handleWebSocket(base, requestPath, query, request, socket, head))) {
|
||||
throw new HttpError("Not found", HttpCode.NotFound)
|
||||
}
|
||||
} catch (error) {
|
||||
socket.destroy(error)
|
||||
logger.warn(`discarding socket connection: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a request URL so we can route it.
|
||||
*/
|
||||
private parseUrl(request: http.IncomingMessage): ProviderRoute {
|
||||
const parse = (fullPath: string): { base: string; requestPath: string } => {
|
||||
const match = fullPath.match(/^(\/?[^/]*)(.*)$/)
|
||||
let [, /* ignore */ base, requestPath] = match ? match.map((p) => p.replace(/\/+$/, "")) : ["", "", ""]
|
||||
if (base.indexOf(".") !== -1) {
|
||||
// Assume it's a file at the root.
|
||||
requestPath = base
|
||||
base = "/"
|
||||
} else if (base === "") {
|
||||
// Happens if it's a plain `domain.com`.
|
||||
base = "/"
|
||||
}
|
||||
requestPath = requestPath || "/index.html"
|
||||
// Allow for a versioned static endpoint. This lets us cache every static
|
||||
// resource underneath the path based on the version without any work and
|
||||
// without adding query parameters which have their own issues.
|
||||
if (/^\/static-/.test(base)) {
|
||||
base = "/static"
|
||||
}
|
||||
|
||||
return { base, requestPath }
|
||||
}
|
||||
|
||||
const parsedUrl = request.url ? url.parse(request.url, true) : { query: {}, pathname: "" }
|
||||
const originalPath = parsedUrl.pathname || ""
|
||||
const fullPath = normalize(originalPath)
|
||||
const { base, requestPath } = parse(fullPath)
|
||||
|
||||
// Providers match on the path after their base so we need to account for
|
||||
// that by shifting the next base out of the request path.
|
||||
let provider = this.providers.get(base)
|
||||
if (base !== "/" && provider) {
|
||||
return { ...parse(requestPath), fullPath, query: parsedUrl.query, provider, originalPath }
|
||||
}
|
||||
|
||||
// Fall back to the top-level provider.
|
||||
provider = this.providers.get("/")
|
||||
if (!provider) {
|
||||
throw new Error(`No provider for ${base}`)
|
||||
}
|
||||
return { base, fullPath, requestPath, query: parsedUrl.query, provider, originalPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the request URL with the specified base and new path.
|
||||
*/
|
||||
private constructRedirect(host: string, oldPath: string, newPath: string, query: Query): string {
|
||||
if (oldPath && oldPath !== "/" && !query.to && /\/login(\/|$)/.test(newPath) && !/\/login(\/|$)/.test(oldPath)) {
|
||||
query.to = oldPath
|
||||
}
|
||||
Object.keys(query).forEach((key) => {
|
||||
if (typeof query[key] === "undefined") {
|
||||
delete query[key]
|
||||
}
|
||||
})
|
||||
return (
|
||||
`${this.protocol}://${host}${newPath}` + (Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "")
|
||||
)
|
||||
}
|
||||
}
|
@ -1,124 +0,0 @@
|
||||
import * as appInsights from "applicationinsights";
|
||||
import * as https from "https";
|
||||
import * as http from "http";
|
||||
import * as os from "os";
|
||||
|
||||
class Channel {
|
||||
public get _sender() {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
public get _buffer() {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public setUseDiskRetryCaching(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
public send(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
public triggerSend(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
}
|
||||
|
||||
export class TelemetryClient {
|
||||
public context: any = undefined;
|
||||
public commonProperties: any = undefined;
|
||||
public config: any = {};
|
||||
|
||||
public channel: any = new Channel();
|
||||
|
||||
public addTelemetryProcessor(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public clearTelemetryProcessors(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public runTelemetryProcessors(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackTrace(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackMetric(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackException(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackRequest(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackDependency(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public track(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackNodeHttpRequestSync(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackNodeHttpRequest(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackNodeHttpDependency(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackEvent(options: appInsights.Contracts.EventTelemetry): void {
|
||||
if (!options.properties) {
|
||||
options.properties = {};
|
||||
}
|
||||
if (!options.measurements) {
|
||||
options.measurements = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cpus = os.cpus();
|
||||
options.measurements.cores = cpus.length;
|
||||
options.properties["common.cpuModel"] = cpus[0].model;
|
||||
} catch (error) {}
|
||||
|
||||
try {
|
||||
options.measurements.memoryFree = os.freemem();
|
||||
options.measurements.memoryTotal = os.totalmem();
|
||||
} catch (error) {}
|
||||
|
||||
try {
|
||||
options.properties["common.shell"] = os.userInfo().shell;
|
||||
options.properties["common.release"] = os.release();
|
||||
options.properties["common.arch"] = os.arch();
|
||||
} catch (error) {}
|
||||
|
||||
try {
|
||||
const url = process.env.TELEMETRY_URL || "https://v1.telemetry.coder.com/track";
|
||||
const request = (/^http:/.test(url) ? http : https).request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
request.on("error", () => { /* We don"t care. */ });
|
||||
request.write(JSON.stringify(options));
|
||||
request.end();
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
public flush(options: { callback: (v: string) => void }): void {
|
||||
if (options.callback) {
|
||||
options.callback("");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import * as cp from "child_process";
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
|
||||
enum ControlMessage {
|
||||
okToChild = "ok>",
|
||||
okFromChild = "ok<",
|
||||
}
|
||||
|
||||
interface RelaunchMessage {
|
||||
type: "relaunch";
|
||||
version: string;
|
||||
}
|
||||
|
||||
export type Message = RelaunchMessage;
|
||||
|
||||
class IpcMain {
|
||||
protected readonly _onMessage = new Emitter<Message>();
|
||||
public readonly onMessage = this._onMessage.event;
|
||||
|
||||
public handshake(child?: cp.ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = child || process;
|
||||
if (!target.send) {
|
||||
throw new Error("Not spawned with IPC enabled");
|
||||
}
|
||||
target.on("message", (message) => {
|
||||
if (message === child ? ControlMessage.okFromChild : ControlMessage.okToChild) {
|
||||
target.removeAllListeners();
|
||||
target.on("message", (msg) => this._onMessage.fire(msg));
|
||||
if (child) {
|
||||
target.send!(ControlMessage.okToChild);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
if (child) {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
const error = new Error(`Unexpected exit with code ${code}`);
|
||||
(error as any).code = code;
|
||||
reject(error);
|
||||
});
|
||||
} else {
|
||||
target.send(ControlMessage.okFromChild);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public relaunch(version: string): void {
|
||||
this.send({ type: "relaunch", version });
|
||||
}
|
||||
|
||||
private send(message: Message): void {
|
||||
if (!process.send) {
|
||||
throw new Error("Not a child process with IPC enabled");
|
||||
}
|
||||
process.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcMain = new IpcMain();
|
@ -1,176 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import { CancellationToken } from "vs/base/common/cancellation";
|
||||
import { mkdirp } from "vs/base/node/pfs";
|
||||
import * as vszip from "vs/base/node/zip";
|
||||
import * as nls from "vs/nls";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { localRequire } from "vs/server/src/node/util";
|
||||
|
||||
const tarStream = localRequire<typeof import("tar-stream")>("tar-stream/index");
|
||||
|
||||
// We will be overriding these, so keep a reference to the original.
|
||||
const vszipExtract = vszip.extract;
|
||||
const vszipBuffer = vszip.buffer;
|
||||
|
||||
export interface IExtractOptions {
|
||||
overwrite?: boolean;
|
||||
/**
|
||||
* Source path within the TAR/ZIP archive. Only the files
|
||||
* contained in this path will be extracted.
|
||||
*/
|
||||
sourcePath?: string;
|
||||
}
|
||||
|
||||
export interface IFile {
|
||||
path: string;
|
||||
contents?: Buffer | string;
|
||||
localPath?: string;
|
||||
}
|
||||
|
||||
export const tar = async (tarPath: string, files: IFile[]): Promise<string> => {
|
||||
const pack = tarStream.pack();
|
||||
const chunks: Buffer[] = [];
|
||||
const ended = new Promise<Buffer>((resolve) => {
|
||||
pack.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
pack.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
pack.entry({ name: file.path }, file.contents);
|
||||
}
|
||||
pack.finalize();
|
||||
await util.promisify(fs.writeFile)(tarPath, await ended);
|
||||
return tarPath;
|
||||
};
|
||||
|
||||
export const extract = async (archivePath: string, extractPath: string, options: IExtractOptions = {}, token: CancellationToken): Promise<void> => {
|
||||
try {
|
||||
await extractTar(archivePath, extractPath, options, token);
|
||||
} catch (error) {
|
||||
if (error.toString().includes("Invalid tar header")) {
|
||||
await vszipExtract(archivePath, extractPath, options, token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buffer = (targetPath: string, filePath: string): Promise<Buffer> => {
|
||||
return new Promise<Buffer>(async (resolve, reject) => {
|
||||
try {
|
||||
let done: boolean = false;
|
||||
await extractAssets(targetPath, new RegExp(filePath), (assetPath: string, data: Buffer) => {
|
||||
if (path.normalize(assetPath) === path.normalize(filePath)) {
|
||||
done = true;
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
if (!done) {
|
||||
throw new Error("couldn't find asset " + filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.toString().includes("Invalid tar header")) {
|
||||
vszipBuffer(targetPath, filePath).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const extractAssets = async (tarPath: string, match: RegExp, callback: (path: string, data: Buffer) => void): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject): void => {
|
||||
const extractor = tarStream.extract();
|
||||
const fail = (error: Error) => {
|
||||
extractor.destroy();
|
||||
reject(error);
|
||||
};
|
||||
extractor.once("error", fail);
|
||||
extractor.on("entry", async (header, stream, next) => {
|
||||
const name = header.name;
|
||||
if (match.test(name)) {
|
||||
extractData(stream).then((data) => {
|
||||
callback(name, data);
|
||||
next();
|
||||
}).catch(fail);
|
||||
} else {
|
||||
stream.on("end", () => next());
|
||||
stream.resume(); // Just drain it.
|
||||
}
|
||||
});
|
||||
extractor.on("finish", resolve);
|
||||
fs.createReadStream(tarPath).pipe(extractor);
|
||||
});
|
||||
};
|
||||
|
||||
const extractData = (stream: NodeJS.ReadableStream): Promise<Buffer> => {
|
||||
return new Promise((resolve, reject): void => {
|
||||
const fileData: Buffer[] = [];
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(fileData)));
|
||||
stream.on("data", (data) => fileData.push(data));
|
||||
});
|
||||
};
|
||||
|
||||
const extractTar = async (tarPath: string, targetPath: string, options: IExtractOptions = {}, token: CancellationToken): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject): void => {
|
||||
const sourcePathRegex = new RegExp(options.sourcePath ? `^${options.sourcePath}` : "");
|
||||
const extractor = tarStream.extract();
|
||||
const fail = (error: Error) => {
|
||||
extractor.destroy();
|
||||
reject(error);
|
||||
};
|
||||
extractor.once("error", fail);
|
||||
extractor.on("entry", async (header, stream, next) => {
|
||||
const nextEntry = (): void => {
|
||||
stream.on("end", () => next());
|
||||
stream.resume();
|
||||
};
|
||||
|
||||
const rawName = path.normalize(header.name);
|
||||
if (token.isCancellationRequested || !sourcePathRegex.test(rawName)) {
|
||||
return nextEntry();
|
||||
}
|
||||
|
||||
const fileName = rawName.replace(sourcePathRegex, "");
|
||||
const targetFileName = path.join(targetPath, fileName);
|
||||
if (/\/$/.test(fileName)) {
|
||||
return mkdirp(targetFileName).then(nextEntry);
|
||||
}
|
||||
|
||||
const dirName = path.dirname(fileName);
|
||||
const targetDirName = path.join(targetPath, dirName);
|
||||
if (targetDirName.indexOf(targetPath) !== 0) {
|
||||
return fail(new Error(nls.localize("invalid file", "Error extracting {0}. Invalid file.", fileName)));
|
||||
}
|
||||
|
||||
await mkdirp(targetDirName, undefined);
|
||||
|
||||
const fstream = fs.createWriteStream(targetFileName, { mode: header.mode });
|
||||
fstream.once("close", () => next());
|
||||
fstream.once("error", fail);
|
||||
stream.pipe(fstream);
|
||||
});
|
||||
extractor.once("finish", resolve);
|
||||
fs.createReadStream(tarPath).pipe(extractor);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Override original functionality so we can use a custom marketplace with
|
||||
* either tars or zips.
|
||||
*/
|
||||
export const enableCustomMarketplace = (): void => {
|
||||
(<any>product).extensionsGallery = { // Use `any` to override readonly.
|
||||
serviceUrl: process.env.SERVICE_URL || "https://v1.extapi.coder.com",
|
||||
itemUrl: process.env.ITEM_URL || "",
|
||||
controlUrl: "",
|
||||
recommendationsUrl: "",
|
||||
...(product.extensionsGallery || {}),
|
||||
};
|
||||
|
||||
const target = vszip as typeof vszip;
|
||||
target.zip = tar;
|
||||
target.extract = extract;
|
||||
target.buffer = buffer;
|
||||
};
|
@ -1,86 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import { getPathFromAmdModule } from "vs/base/common/amd";
|
||||
import * as lp from "vs/base/node/languagePacks";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { Translations } from "vs/workbench/services/extensions/common/extensionPoints";
|
||||
|
||||
const configurations = new Map<string, Promise<lp.NLSConfiguration>>();
|
||||
const metadataPath = path.join(getPathFromAmdModule(require, ""), "nls.metadata.json");
|
||||
|
||||
export const isInternalConfiguration = (config: lp.NLSConfiguration): config is lp.InternalNLSConfiguration => {
|
||||
return config && !!(<lp.InternalNLSConfiguration>config)._languagePackId;
|
||||
};
|
||||
|
||||
const DefaultConfiguration = {
|
||||
locale: "en",
|
||||
availableLanguages: {},
|
||||
};
|
||||
|
||||
export const getNlsConfiguration = async (locale: string, userDataPath: string): Promise<lp.NLSConfiguration> => {
|
||||
const id = `${locale}: ${userDataPath}`;
|
||||
if (!configurations.has(id)) {
|
||||
configurations.set(id, new Promise(async (resolve) => {
|
||||
const config = product.commit && await util.promisify(fs.exists)(metadataPath)
|
||||
? await lp.getNLSConfiguration(product.commit, userDataPath, metadataPath, locale)
|
||||
: DefaultConfiguration;
|
||||
if (isInternalConfiguration(config)) {
|
||||
config._languagePackSupport = true;
|
||||
}
|
||||
// If the configuration has no results keep trying since code-server
|
||||
// doesn't restart when a language is installed so this result would
|
||||
// persist (the plugin might not be installed yet or something).
|
||||
if (config.locale !== "en" && config.locale !== "en-us" && Object.keys(config.availableLanguages).length === 0) {
|
||||
configurations.delete(id);
|
||||
}
|
||||
resolve(config);
|
||||
}));
|
||||
}
|
||||
return configurations.get(id)!;
|
||||
};
|
||||
|
||||
export const getTranslations = async (locale: string, userDataPath: string): Promise<Translations> => {
|
||||
const config = await getNlsConfiguration(locale, userDataPath);
|
||||
if (isInternalConfiguration(config)) {
|
||||
try {
|
||||
return JSON.parse(await util.promisify(fs.readFile)(config._translationsConfigFile, "utf8"));
|
||||
} catch (error) { /* Nothing yet. */}
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const getLocaleFromConfig = async (userDataPath: string): Promise<string> => {
|
||||
let locale = "en";
|
||||
try {
|
||||
const localeConfigUri = path.join(userDataPath, "User/locale.json");
|
||||
const content = stripComments(await util.promisify(fs.readFile)(localeConfigUri, "utf8"));
|
||||
locale = JSON.parse(content).locale;
|
||||
} catch (error) { /* Ignore. */ }
|
||||
return locale;
|
||||
};
|
||||
|
||||
// Taken from src/main.js in the main VS Code source.
|
||||
const stripComments = (content: string): string => {
|
||||
const regexp = /("(?:[^\\"]*(?:\\.)?)*")|('(?:[^\\']*(?:\\.)?)*')|(\/\*(?:\r?\n|.)*?\*\/)|(\/{2,}.*?(?:(?:\r?\n)|$))/g;
|
||||
|
||||
return content.replace(regexp, (match, _m1, _m2, m3, m4) => {
|
||||
// Only one of m1, m2, m3, m4 matches
|
||||
if (m3) {
|
||||
// A block comment. Replace with nothing
|
||||
return '';
|
||||
} else if (m4) {
|
||||
// A line comment. If it ends in \r?\n then keep it.
|
||||
const length_1 = m4.length;
|
||||
if (length_1 > 2 && m4[length_1 - 1] === '\n') {
|
||||
return m4[length_1 - 2] === '\r' ? '\r\n' : '\n';
|
||||
}
|
||||
else {
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
// We match a string
|
||||
return match;
|
||||
}
|
||||
});
|
||||
};
|
@ -1,73 +0,0 @@
|
||||
import * as net from "net";
|
||||
import { VSBuffer } from "vs/base/common/buffer";
|
||||
import { PersistentProtocol } from "vs/base/parts/ipc/common/ipc.net";
|
||||
import { NodeSocket, WebSocketNodeSocket } from "vs/base/parts/ipc/node/ipc.net";
|
||||
import { AuthRequest, ConnectionTypeRequest, HandshakeMessage } from "vs/platform/remote/common/remoteAgentConnection";
|
||||
|
||||
export interface SocketOptions {
|
||||
readonly reconnectionToken: string;
|
||||
readonly reconnection: boolean;
|
||||
readonly skipWebSocketFrames: boolean;
|
||||
}
|
||||
|
||||
export class Protocol extends PersistentProtocol {
|
||||
public constructor(socket: net.Socket, public readonly options: SocketOptions) {
|
||||
super(
|
||||
options.skipWebSocketFrames
|
||||
? new NodeSocket(socket)
|
||||
: new WebSocketNodeSocket(new NodeSocket(socket)),
|
||||
);
|
||||
}
|
||||
|
||||
public getUnderlyingSocket(): net.Socket {
|
||||
const socket = this.getSocket();
|
||||
return socket instanceof NodeSocket
|
||||
? socket.socket
|
||||
: (socket as WebSocketNodeSocket).socket.socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a handshake to get a connection request.
|
||||
*/
|
||||
public handshake(): Promise<ConnectionTypeRequest> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = this.onControlMessage((rawMessage) => {
|
||||
try {
|
||||
const message = JSON.parse(rawMessage.toString());
|
||||
switch (message.type) {
|
||||
case "auth": return this.authenticate(message);
|
||||
case "connectionType":
|
||||
handler.dispose();
|
||||
return resolve(message);
|
||||
default: throw new Error("Unrecognized message type");
|
||||
}
|
||||
} catch (error) {
|
||||
handler.dispose();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: This ignores the authentication process entirely for now.
|
||||
*/
|
||||
private authenticate(_message: AuthRequest): void {
|
||||
this.sendMessage({ type: "sign", data: "" });
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: implement.
|
||||
*/
|
||||
public tunnel(): void {
|
||||
throw new Error("Tunnel is not implemented yet");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a handshake message. In the case of the extension host, it just sends
|
||||
* back a debug port.
|
||||
*/
|
||||
public sendMessage(message: HandshakeMessage | { debugPort?: number } ): void {
|
||||
this.sendControl(VSBuffer.fromString(JSON.stringify(message)));
|
||||
}
|
||||
}
|
@ -1,957 +0,0 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as http from "http";
|
||||
import * as https from "https";
|
||||
import * as net from "net";
|
||||
import * as path from "path";
|
||||
import * as querystring from "querystring";
|
||||
import { Readable } from "stream";
|
||||
import * as tls from "tls";
|
||||
import * as url from "url";
|
||||
import * as util from "util";
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
import { sanitizeFilePath } from "vs/base/common/extpath";
|
||||
import { Schemas } from "vs/base/common/network";
|
||||
import { URI, UriComponents } from "vs/base/common/uri";
|
||||
import { generateUuid } from "vs/base/common/uuid";
|
||||
import { getMachineId } from 'vs/base/node/id';
|
||||
import { NLSConfiguration } from "vs/base/node/languagePacks";
|
||||
import { mkdirp, rimraf } from "vs/base/node/pfs";
|
||||
import { ClientConnectionEvent, IPCServer, IServerChannel } from "vs/base/parts/ipc/common/ipc";
|
||||
import { createChannelReceiver } from "vs/base/parts/ipc/node/ipc";
|
||||
import { LogsDataCleaner } from "vs/code/electron-browser/sharedProcess/contrib/logsDataCleaner";
|
||||
import { IConfigurationService } from "vs/platform/configuration/common/configuration";
|
||||
import { ConfigurationService } from "vs/platform/configuration/node/configurationService";
|
||||
import { ExtensionHostDebugBroadcastChannel } from "vs/platform/debug/common/extensionHostDebugIpc";
|
||||
import { IEnvironmentService, ParsedArgs } from "vs/platform/environment/common/environment";
|
||||
import { EnvironmentService } from "vs/platform/environment/node/environmentService";
|
||||
import { ExtensionGalleryService } from "vs/platform/extensionManagement/common/extensionGalleryService";
|
||||
import { IExtensionGalleryService, IExtensionManagementService } from "vs/platform/extensionManagement/common/extensionManagement";
|
||||
import { ExtensionManagementChannel } from "vs/platform/extensionManagement/common/extensionManagementIpc";
|
||||
import { ExtensionManagementService } from "vs/platform/extensionManagement/node/extensionManagementService";
|
||||
import { IFileService } from "vs/platform/files/common/files";
|
||||
import { FileService } from "vs/platform/files/common/fileService";
|
||||
import { DiskFileSystemProvider } from "vs/platform/files/node/diskFileSystemProvider";
|
||||
import { SyncDescriptor } from "vs/platform/instantiation/common/descriptors";
|
||||
import { InstantiationService } from "vs/platform/instantiation/common/instantiationService";
|
||||
import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection";
|
||||
import { ILocalizationsService } from "vs/platform/localizations/common/localizations";
|
||||
import { LocalizationsService } from "vs/platform/localizations/node/localizations";
|
||||
import { getLogLevel, ILogService } from "vs/platform/log/common/log";
|
||||
import { LoggerChannel } from "vs/platform/log/common/logIpc";
|
||||
import { SpdLogService } from "vs/platform/log/node/spdlogService";
|
||||
import product from 'vs/platform/product/common/product';
|
||||
import { IProductService } from "vs/platform/product/common/productService";
|
||||
import { ConnectionType, ConnectionTypeRequest } from "vs/platform/remote/common/remoteAgentConnection";
|
||||
import { RemoteAgentConnectionContext } from "vs/platform/remote/common/remoteAgentEnvironment";
|
||||
import { REMOTE_FILE_SYSTEM_CHANNEL_NAME } from "vs/platform/remote/common/remoteAgentFileSystemChannel";
|
||||
import { IRequestService } from "vs/platform/request/common/request";
|
||||
import { RequestChannel } from "vs/platform/request/common/requestIpc";
|
||||
import { RequestService } from "vs/platform/request/node/requestService";
|
||||
import ErrorTelemetry from "vs/platform/telemetry/browser/errorTelemetry";
|
||||
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
|
||||
import { ITelemetryServiceConfig, TelemetryService } from "vs/platform/telemetry/common/telemetryService";
|
||||
import { combinedAppender, LogAppender, NullTelemetryService } from "vs/platform/telemetry/common/telemetryUtils";
|
||||
import { AppInsightsAppender } from "vs/platform/telemetry/node/appInsightsAppender";
|
||||
import { resolveCommonProperties } from "vs/platform/telemetry/node/commonProperties";
|
||||
import { UpdateChannel } from "vs/platform/update/electron-main/updateIpc";
|
||||
import { INodeProxyService, NodeProxyChannel } from "vs/server/src/common/nodeProxy";
|
||||
import { TelemetryChannel } from "vs/server/src/common/telemetry";
|
||||
import { split } from "vs/server/src/common/util";
|
||||
import { ExtensionEnvironmentChannel, FileProviderChannel, NodeProxyService } from "vs/server/src/node/channel";
|
||||
import { Connection, ExtensionHostConnection, ManagementConnection } from "vs/server/src/node/connection";
|
||||
import { TelemetryClient } from "vs/server/src/node/insights";
|
||||
import { getLocaleFromConfig, getNlsConfiguration } from "vs/server/src/node/nls";
|
||||
import { Protocol } from "vs/server/src/node/protocol";
|
||||
import { UpdateService } from "vs/server/src/node/update";
|
||||
import { AuthType, getMediaMime, getUriTransformer, hash, localRequire, tmpdir } from "vs/server/src/node/util";
|
||||
import { RemoteExtensionLogFileName } from "vs/workbench/services/remote/common/remoteAgentService";
|
||||
import { IWorkbenchConstructionOptions } from "vs/workbench/workbench.web.api";
|
||||
|
||||
const tarFs = localRequire<typeof import("tar-fs")>("tar-fs/index");
|
||||
|
||||
export enum HttpCode {
|
||||
Ok = 200,
|
||||
Redirect = 302,
|
||||
NotFound = 404,
|
||||
BadRequest = 400,
|
||||
Unauthorized = 401,
|
||||
LargePayload = 413,
|
||||
ServerError = 500,
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
WORKBENCH_WEB_CONFIGURATION: IWorkbenchConstructionOptions & { folderUri?: UriComponents, workspaceUri?: UriComponents };
|
||||
REMOTE_USER_DATA_URI: UriComponents | URI;
|
||||
PRODUCT_CONFIGURATION: Partial<IProductService>;
|
||||
NLS_CONFIGURATION: NLSConfiguration;
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
cache?: boolean;
|
||||
code?: number;
|
||||
content?: string | Buffer;
|
||||
filePath?: string;
|
||||
headers?: http.OutgoingHttpHeaders;
|
||||
mime?: string;
|
||||
redirect?: string;
|
||||
stream?: Readable;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface AuthPayload {
|
||||
key?: string[];
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
public constructor(message: string, public readonly code: number) {
|
||||
super(message);
|
||||
// @ts-ignore
|
||||
this.name = this.constructor.name;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerOptions {
|
||||
readonly auth: AuthType;
|
||||
readonly basePath?: string;
|
||||
readonly connectionToken?: string;
|
||||
readonly cert?: string;
|
||||
readonly certKey?: string;
|
||||
readonly openUri?: string;
|
||||
readonly host?: string;
|
||||
readonly password?: string;
|
||||
readonly port?: number;
|
||||
readonly socket?: string;
|
||||
}
|
||||
|
||||
export abstract class Server {
|
||||
protected readonly server: http.Server | https.Server;
|
||||
protected rootPath = path.resolve(__dirname, "../../../../..");
|
||||
protected serverRoot = path.join(this.rootPath, "/out/vs/server/src");
|
||||
protected readonly allowedRequestPaths: string[] = [this.rootPath];
|
||||
private listenPromise: Promise<string> | undefined;
|
||||
public readonly protocol: "http" | "https";
|
||||
public readonly options: ServerOptions;
|
||||
|
||||
public constructor(options: ServerOptions) {
|
||||
this.options = {
|
||||
host: options.auth === "password" && options.cert ? "0.0.0.0" : "localhost",
|
||||
...options,
|
||||
basePath: options.basePath ? options.basePath.replace(/\/+$/, "") : "",
|
||||
password: options.password ? hash(options.password) : undefined,
|
||||
};
|
||||
this.protocol = this.options.cert ? "https" : "http";
|
||||
if (this.protocol === "https") {
|
||||
const httpolyglot = localRequire<typeof import("httpolyglot")>("httpolyglot/lib/index");
|
||||
this.server = httpolyglot.createServer({
|
||||
cert: this.options.cert && fs.readFileSync(this.options.cert),
|
||||
key: this.options.certKey && fs.readFileSync(this.options.certKey),
|
||||
}, this.onRequest);
|
||||
} else {
|
||||
this.server = http.createServer(this.onRequest);
|
||||
}
|
||||
}
|
||||
|
||||
public listen(): Promise<string> {
|
||||
if (!this.listenPromise) {
|
||||
this.listenPromise = new Promise((resolve, reject) => {
|
||||
this.server.on("error", reject);
|
||||
this.server.on("upgrade", this.onUpgrade);
|
||||
const onListen = () => resolve(this.address());
|
||||
if (this.options.socket) {
|
||||
this.server.listen(this.options.socket, onListen);
|
||||
} else {
|
||||
this.server.listen(this.options.port, this.options.host, onListen);
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.listenPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* The *local* address of the server.
|
||||
*/
|
||||
public address(): string {
|
||||
const address = this.server.address();
|
||||
const endpoint = typeof address !== "string"
|
||||
? (address.address === "::" ? "localhost" : address.address) + ":" + address.port
|
||||
: address;
|
||||
return `${this.protocol}://${endpoint}`;
|
||||
}
|
||||
|
||||
protected abstract handleWebSocket(
|
||||
socket: net.Socket,
|
||||
parsedUrl: url.UrlWithParsedQuery
|
||||
): Promise<void>;
|
||||
|
||||
protected abstract handleRequest(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
parsedUrl: url.UrlWithParsedQuery,
|
||||
request: http.IncomingMessage,
|
||||
): Promise<Response>;
|
||||
|
||||
protected async getResource(...parts: string[]): Promise<Response> {
|
||||
const filePath = this.ensureAuthorizedFilePath(...parts);
|
||||
return { content: await util.promisify(fs.readFile)(filePath), filePath };
|
||||
}
|
||||
|
||||
protected async getAnyResource(...parts: string[]): Promise<Response> {
|
||||
const filePath = path.join(...parts);
|
||||
return { content: await util.promisify(fs.readFile)(filePath), filePath };
|
||||
}
|
||||
|
||||
protected async getTarredResource(...parts: string[]): Promise<Response> {
|
||||
const filePath = this.ensureAuthorizedFilePath(...parts);
|
||||
return { stream: tarFs.pack(filePath), filePath, mime: "application/tar", cache: true };
|
||||
}
|
||||
|
||||
protected ensureAuthorizedFilePath(...parts: string[]): string {
|
||||
const filePath = path.join(...parts);
|
||||
if (!this.isAllowedRequestPath(filePath)) {
|
||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
protected withBase(request: http.IncomingMessage, path: string): string {
|
||||
const [, query] = request.url ? split(request.url, "?") : [];
|
||||
return `${this.protocol}://${request.headers.host}${this.options.basePath}${path}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
private isAllowedRequestPath(path: string): boolean {
|
||||
for (let i = 0; i < this.allowedRequestPaths.length; ++i) {
|
||||
if (path.indexOf(this.allowedRequestPaths[i]) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => {
|
||||
try {
|
||||
const parsedUrl = request.url ? url.parse(request.url, true) : { query: {}};
|
||||
const payload = await this.preHandleRequest(request, parsedUrl);
|
||||
response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, {
|
||||
"Content-Type": payload.mime || getMediaMime(payload.filePath),
|
||||
...(payload.redirect ? { Location: this.withBase(request, payload.redirect) } : {}),
|
||||
...(request.headers["service-worker"] ? { "Service-Worker-Allowed": this.options.basePath || "/" } : {}),
|
||||
...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}),
|
||||
...payload.headers,
|
||||
});
|
||||
if (payload.stream) {
|
||||
payload.stream.on("error", (error: NodeJS.ErrnoException) => {
|
||||
response.writeHead(error.code === "ENOENT" ? HttpCode.NotFound : HttpCode.ServerError);
|
||||
response.end(error.message);
|
||||
});
|
||||
payload.stream.pipe(response);
|
||||
} else {
|
||||
response.end(payload.content);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT" || error.code === "EISDIR") {
|
||||
error = new HttpError("Not found", HttpCode.NotFound);
|
||||
}
|
||||
response.writeHead(typeof error.code === "number" ? error.code : HttpCode.ServerError);
|
||||
response.end(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async preHandleRequest(request: http.IncomingMessage, parsedUrl: url.UrlWithParsedQuery): Promise<Response> {
|
||||
const secure = (request.connection as tls.TLSSocket).encrypted;
|
||||
if (this.options.cert && !secure) {
|
||||
return { redirect: request.url };
|
||||
}
|
||||
|
||||
const fullPath = decodeURIComponent(parsedUrl.pathname || "/");
|
||||
const match = fullPath.match(/^(\/?[^/]*)(.*)$/);
|
||||
let [/* ignore */, base, requestPath] = match
|
||||
? match.map((p) => p.replace(/\/+$/, ""))
|
||||
: ["", "", ""];
|
||||
if (base.indexOf(".") !== -1) { // Assume it's a file at the root.
|
||||
requestPath = base;
|
||||
base = "/";
|
||||
} else if (base === "") { // Happens if it's a plain `domain.com`.
|
||||
base = "/";
|
||||
}
|
||||
base = path.normalize(base);
|
||||
requestPath = path.normalize(requestPath || "/index.html");
|
||||
|
||||
if (base !== "/login" || this.options.auth !== "password" || requestPath !== "/index.html") {
|
||||
this.ensureGet(request);
|
||||
}
|
||||
|
||||
// Allow for a versioned static endpoint. This lets us cache every static
|
||||
// resource underneath the path based on the version without any work and
|
||||
// without adding query parameters which have their own issues.
|
||||
// REVIEW: Discuss whether this is the best option; this is sort of a quick
|
||||
// hack almost to get caching in the meantime but it does work pretty well.
|
||||
if (/^\/static-/.test(base)) {
|
||||
base = "/static";
|
||||
}
|
||||
|
||||
switch (base) {
|
||||
case "/":
|
||||
switch (requestPath) {
|
||||
// NOTE: This must be served at the correct location based on the
|
||||
// start_url in the manifest.
|
||||
case "/manifest.json":
|
||||
case "/code-server.png":
|
||||
const response = await this.getResource(this.serverRoot, "media", requestPath);
|
||||
response.cache = true;
|
||||
return response;
|
||||
}
|
||||
if (!this.authenticate(request)) {
|
||||
return { redirect: "/login" };
|
||||
}
|
||||
break;
|
||||
case "/static":
|
||||
const response = await this.getResource(this.rootPath, requestPath);
|
||||
response.cache = true;
|
||||
return response;
|
||||
case "/login":
|
||||
if (this.options.auth !== "password" || requestPath !== "/index.html") {
|
||||
throw new HttpError("Not found", HttpCode.NotFound);
|
||||
}
|
||||
return this.tryLogin(request);
|
||||
default:
|
||||
if (!this.authenticate(request)) {
|
||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return this.handleRequest(base, requestPath, parsedUrl, request);
|
||||
}
|
||||
|
||||
private onUpgrade = async (request: http.IncomingMessage, socket: net.Socket): Promise<void> => {
|
||||
try {
|
||||
await this.preHandleWebSocket(request, socket);
|
||||
} catch (error) {
|
||||
socket.destroy();
|
||||
console.error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private preHandleWebSocket(request: http.IncomingMessage, socket: net.Socket): Promise<void> {
|
||||
socket.on("error", () => socket.destroy());
|
||||
socket.on("end", () => socket.destroy());
|
||||
|
||||
this.ensureGet(request);
|
||||
if (!this.authenticate(request)) {
|
||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized);
|
||||
} else if (!request.headers.upgrade || request.headers.upgrade.toLowerCase() !== "websocket") {
|
||||
throw new Error("HTTP/1.1 400 Bad Request");
|
||||
}
|
||||
|
||||
// This magic value is specified by the websocket spec.
|
||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
const reply = crypto.createHash("sha1")
|
||||
.update(<string>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 parsedUrl = request.url ? url.parse(request.url, true) : { query: {}};
|
||||
return this.handleWebSocket(socket, parsedUrl);
|
||||
}
|
||||
|
||||
private async tryLogin(request: http.IncomingMessage): Promise<Response> {
|
||||
const redirect = (password: string | true) => {
|
||||
return {
|
||||
redirect: "/",
|
||||
headers: typeof password === "string"
|
||||
? { "Set-Cookie": `key=${password}; Path=${this.options.basePath || "/"}; HttpOnly; SameSite=strict` }
|
||||
: {},
|
||||
};
|
||||
};
|
||||
const providedPassword = this.authenticate(request);
|
||||
if (providedPassword && (request.method === "GET" || request.method === "POST")) {
|
||||
return redirect(providedPassword);
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
const data = await this.getData<LoginPayload>(request);
|
||||
const password = this.authenticate(request, {
|
||||
key: typeof data.password === "string" ? [hash(data.password)] : undefined,
|
||||
});
|
||||
if (password) {
|
||||
return redirect(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),
|
||||
}));
|
||||
return this.getLogin("Invalid password", data);
|
||||
}
|
||||
this.ensureGet(request);
|
||||
return this.getLogin();
|
||||
}
|
||||
|
||||
private async getLogin(error: string = "", payload?: LoginPayload): Promise<Response> {
|
||||
const filePath = path.join(this.serverRoot, "browser/login.html");
|
||||
const content = (await util.promisify(fs.readFile)(filePath, "utf8"))
|
||||
.replace("{{ERROR}}", error)
|
||||
.replace("display:none", error ? "display:block" : "display:none")
|
||||
.replace('value=""', `value="${payload && payload.password || ""}"`);
|
||||
return { content, filePath };
|
||||
}
|
||||
|
||||
private ensureGet(request: http.IncomingMessage): void {
|
||||
if (request.method !== "GET") {
|
||||
throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest);
|
||||
}
|
||||
}
|
||||
|
||||
private getData<T extends object>(request: http.IncomingMessage): Promise<T> {
|
||||
return request.method === "POST"
|
||||
? new Promise<T>((resolve, reject) => {
|
||||
let body = "";
|
||||
const onEnd = (): void => {
|
||||
off();
|
||||
resolve(querystring.parse(body) as T);
|
||||
};
|
||||
const onError = (error: Error): void => {
|
||||
off();
|
||||
reject(error);
|
||||
};
|
||||
const onData = (d: Buffer): void => {
|
||||
body += d;
|
||||
if (body.length > 1e6) {
|
||||
onError(new HttpError("Payload is too large", HttpCode.LargePayload));
|
||||
request.connection.destroy();
|
||||
}
|
||||
};
|
||||
const off = (): void => {
|
||||
request.off("error", onError);
|
||||
request.off("data", onError);
|
||||
request.off("end", onEnd);
|
||||
};
|
||||
request.on("error", onError);
|
||||
request.on("data", onData);
|
||||
request.on("end", onEnd);
|
||||
})
|
||||
: Promise.resolve({} as T);
|
||||
}
|
||||
|
||||
private authenticate(request: http.IncomingMessage, payload?: AuthPayload): string | boolean {
|
||||
if (this.options.auth === "none") {
|
||||
return true;
|
||||
}
|
||||
const safeCompare = localRequire<typeof import("safe-compare")>("safe-compare/index");
|
||||
if (typeof payload === "undefined") {
|
||||
payload = this.parseCookies<AuthPayload>(request);
|
||||
}
|
||||
if (this.options.password && payload.key) {
|
||||
for (let i = 0; i < payload.key.length; ++i) {
|
||||
if (safeCompare(payload.key[i], this.options.password)) {
|
||||
return payload.key[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private parseCookies<T extends object>(request: http.IncomingMessage): T {
|
||||
const cookies: { [key: string]: string[] } = {};
|
||||
if (request.headers.cookie) {
|
||||
request.headers.cookie.split(";").forEach((keyValue) => {
|
||||
const [key, value] = split(keyValue, "=");
|
||||
if (!cookies[key]) {
|
||||
cookies[key] = [];
|
||||
}
|
||||
cookies[key].push(decodeURI(value));
|
||||
});
|
||||
}
|
||||
return cookies as T;
|
||||
}
|
||||
}
|
||||
|
||||
interface StartPath {
|
||||
path?: string[] | string;
|
||||
workspace?: boolean;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
lastVisited?: StartPath;
|
||||
}
|
||||
|
||||
export class MainServer extends Server {
|
||||
public readonly _onDidClientConnect = new Emitter<ClientConnectionEvent>();
|
||||
public readonly onDidClientConnect = this._onDidClientConnect.event;
|
||||
private readonly ipc = new IPCServer<RemoteAgentConnectionContext>(this.onDidClientConnect);
|
||||
|
||||
private readonly maxExtraOfflineConnections = 0;
|
||||
private readonly connections = new Map<ConnectionType, Map<string, Connection>>();
|
||||
|
||||
private readonly services = new ServiceCollection();
|
||||
private readonly servicesPromise: Promise<void>;
|
||||
|
||||
public readonly _onProxyConnect = new Emitter<net.Socket>();
|
||||
private proxyPipe = path.join(tmpdir, "tls-proxy");
|
||||
private _proxyServer?: Promise<net.Server>;
|
||||
private readonly proxyTimeout = 5000;
|
||||
|
||||
private settings: Settings = {};
|
||||
private heartbeatTimer?: NodeJS.Timeout;
|
||||
private heartbeatInterval = 60000;
|
||||
private lastHeartbeat = 0;
|
||||
|
||||
public constructor(options: ServerOptions, args: ParsedArgs) {
|
||||
super(options);
|
||||
this.servicesPromise = this.initializeServices(args);
|
||||
}
|
||||
|
||||
public async listen(): Promise<string> {
|
||||
const environment = (this.services.get(IEnvironmentService) as EnvironmentService);
|
||||
const [address] = await Promise.all<string>([
|
||||
super.listen(), ...[
|
||||
environment.extensionsPath,
|
||||
].map((p) => mkdirp(p).then(() => p)),
|
||||
]);
|
||||
return address;
|
||||
}
|
||||
|
||||
protected async handleWebSocket(socket: net.Socket, parsedUrl: url.UrlWithParsedQuery): Promise<void> {
|
||||
this.heartbeat();
|
||||
if (!parsedUrl.query.reconnectionToken) {
|
||||
throw new Error("Reconnection token is missing from query parameters");
|
||||
}
|
||||
const protocol = new Protocol(await this.createProxy(socket), {
|
||||
reconnectionToken: <string>parsedUrl.query.reconnectionToken,
|
||||
reconnection: parsedUrl.query.reconnection === "true",
|
||||
skipWebSocketFrames: parsedUrl.query.skipWebSocketFrames === "true",
|
||||
});
|
||||
try {
|
||||
await this.connect(await protocol.handshake(), protocol);
|
||||
} catch (error) {
|
||||
protocol.sendMessage({ type: "error", reason: error.message });
|
||||
protocol.dispose();
|
||||
protocol.getSocket().dispose();
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleRequest(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
parsedUrl: url.UrlWithParsedQuery,
|
||||
request: http.IncomingMessage,
|
||||
): Promise<Response> {
|
||||
this.heartbeat();
|
||||
switch (base) {
|
||||
case "/": return this.getRoot(request, parsedUrl);
|
||||
case "/resource":
|
||||
case "/vscode-remote-resource":
|
||||
if (typeof parsedUrl.query.path === "string") {
|
||||
return this.getAnyResource(parsedUrl.query.path);
|
||||
}
|
||||
break;
|
||||
case "/tar":
|
||||
if (typeof parsedUrl.query.path === "string") {
|
||||
return this.getTarredResource(parsedUrl.query.path);
|
||||
}
|
||||
break;
|
||||
case "/webview":
|
||||
if (/^\/vscode-resource/.test(requestPath)) {
|
||||
return this.getAnyResource(requestPath.replace(/^\/vscode-resource(\/file)?/, ""));
|
||||
}
|
||||
return this.getResource(
|
||||
this.rootPath,
|
||||
"out/vs/workbench/contrib/webview/browser/pre",
|
||||
requestPath
|
||||
);
|
||||
}
|
||||
throw new HttpError("Not found", HttpCode.NotFound);
|
||||
}
|
||||
|
||||
private async getRoot(request: http.IncomingMessage, parsedUrl: url.UrlWithParsedQuery): Promise<Response> {
|
||||
const filePath = path.join(this.serverRoot, "browser/workbench.html");
|
||||
let [content, startPath] = await Promise.all([
|
||||
util.promisify(fs.readFile)(filePath, "utf8"),
|
||||
this.getFirstValidPath([
|
||||
{ path: parsedUrl.query.workspace, workspace: true },
|
||||
{ path: parsedUrl.query.folder, workspace: false },
|
||||
(await this.readSettings()).lastVisited,
|
||||
{ path: this.options.openUri }
|
||||
]),
|
||||
this.servicesPromise,
|
||||
]);
|
||||
|
||||
if (startPath) {
|
||||
this.writeSettings({
|
||||
lastVisited: {
|
||||
path: startPath.uri.fsPath,
|
||||
workspace: startPath.workspace
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const logger = this.services.get(ILogService) as ILogService;
|
||||
logger.info("request.url", `"${request.url}"`);
|
||||
|
||||
const remoteAuthority = request.headers.host as string;
|
||||
const transformer = getUriTransformer(remoteAuthority);
|
||||
|
||||
const environment = this.services.get(IEnvironmentService) as IEnvironmentService;
|
||||
const options: Options = {
|
||||
WORKBENCH_WEB_CONFIGURATION: {
|
||||
workspaceUri: startPath && startPath.workspace ? transformer.transformOutgoing(startPath.uri) : undefined,
|
||||
folderUri: startPath && !startPath.workspace ? transformer.transformOutgoing(startPath.uri) : undefined,
|
||||
remoteAuthority,
|
||||
logLevel: getLogLevel(environment),
|
||||
},
|
||||
REMOTE_USER_DATA_URI: transformer.transformOutgoing(URI.file(environment.userDataPath)),
|
||||
PRODUCT_CONFIGURATION: {
|
||||
extensionsGallery: product.extensionsGallery,
|
||||
},
|
||||
NLS_CONFIGURATION: await getNlsConfiguration(environment.args.locale || await getLocaleFromConfig(environment.userDataPath), environment.userDataPath),
|
||||
};
|
||||
|
||||
content = content.replace(/{{COMMIT}}/g, product.commit || "");
|
||||
for (const key in options) {
|
||||
content = content.replace(`"{{${key}}}"`, `'${JSON.stringify(options[key as keyof Options])}'`);
|
||||
}
|
||||
|
||||
return { content, filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the first valid path. If `workspace` is undefined then either a
|
||||
* workspace or a directory are acceptable. Otherwise it must be a file if a
|
||||
* workspace or a directory otherwise.
|
||||
*/
|
||||
private async getFirstValidPath(startPaths: Array<StartPath | undefined>): Promise<{ uri: URI, workspace?: boolean} | undefined> {
|
||||
const logger = this.services.get(ILogService) as ILogService;
|
||||
const cwd = process.env.VSCODE_CWD || process.cwd();
|
||||
for (let i = 0; i < startPaths.length; ++i) {
|
||||
const startPath = startPaths[i];
|
||||
if (!startPath) {
|
||||
continue;
|
||||
}
|
||||
const paths = typeof startPath.path === "string" ? [startPath.path] : (startPath.path || []);
|
||||
for (let j = 0; j < paths.length; ++j) {
|
||||
const uri = URI.file(sanitizeFilePath(paths[j], cwd));
|
||||
try {
|
||||
const stat = await util.promisify(fs.stat)(uri.fsPath);
|
||||
if (typeof startPath.workspace === "undefined" || startPath.workspace !== stat.isDirectory()) {
|
||||
return { uri, workspace: !stat.isDirectory() };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async connect(message: ConnectionTypeRequest, protocol: Protocol): Promise<void> {
|
||||
if (product.commit && message.commit !== product.commit) {
|
||||
throw new Error(`Version mismatch (${message.commit} instead of ${product.commit})`);
|
||||
}
|
||||
|
||||
switch (message.desiredConnectionType) {
|
||||
case ConnectionType.ExtensionHost:
|
||||
case ConnectionType.Management:
|
||||
if (!this.connections.has(message.desiredConnectionType)) {
|
||||
this.connections.set(message.desiredConnectionType, new Map());
|
||||
}
|
||||
const connections = this.connections.get(message.desiredConnectionType)!;
|
||||
|
||||
const ok = async () => {
|
||||
return message.desiredConnectionType === ConnectionType.ExtensionHost
|
||||
? { debugPort: await this.getDebugPort() }
|
||||
: { type: "ok" };
|
||||
};
|
||||
|
||||
const token = protocol.options.reconnectionToken;
|
||||
if (protocol.options.reconnection && connections.has(token)) {
|
||||
protocol.sendMessage(await ok());
|
||||
const buffer = protocol.readEntireBuffer();
|
||||
protocol.dispose();
|
||||
return connections.get(token)!.reconnect(protocol.getSocket(), buffer);
|
||||
} else if (protocol.options.reconnection || connections.has(token)) {
|
||||
throw new Error(protocol.options.reconnection
|
||||
? "Unrecognized reconnection token"
|
||||
: "Duplicate reconnection token"
|
||||
);
|
||||
}
|
||||
|
||||
protocol.sendMessage(await ok());
|
||||
|
||||
let connection: Connection;
|
||||
if (message.desiredConnectionType === ConnectionType.Management) {
|
||||
connection = new ManagementConnection(protocol, token);
|
||||
this._onDidClientConnect.fire({
|
||||
protocol, onDidClientDisconnect: connection.onClose,
|
||||
});
|
||||
// TODO: Need a way to match clients with a connection. For now
|
||||
// dispose everything which only works because no extensions currently
|
||||
// utilize long-running proxies.
|
||||
(this.services.get(INodeProxyService) as NodeProxyService)._onUp.fire();
|
||||
connection.onClose(() => (this.services.get(INodeProxyService) as NodeProxyService)._onDown.fire());
|
||||
} else {
|
||||
const buffer = protocol.readEntireBuffer();
|
||||
connection = new ExtensionHostConnection(
|
||||
message.args ? message.args.language : "en",
|
||||
protocol, buffer, token,
|
||||
this.services.get(ILogService) as ILogService,
|
||||
this.services.get(IEnvironmentService) as IEnvironmentService,
|
||||
);
|
||||
}
|
||||
connections.set(token, connection);
|
||||
connection.onClose(() => connections.delete(token));
|
||||
this.disposeOldOfflineConnections(connections);
|
||||
break;
|
||||
case ConnectionType.Tunnel: return protocol.tunnel();
|
||||
default: throw new Error("Unrecognized connection type");
|
||||
}
|
||||
}
|
||||
|
||||
private disposeOldOfflineConnections(connections: Map<string, Connection>): void {
|
||||
const offline = Array.from(connections.values())
|
||||
.filter((connection) => typeof connection.offline !== "undefined");
|
||||
for (let i = 0, max = offline.length - this.maxExtraOfflineConnections; i < max; ++i) {
|
||||
offline[i].dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeServices(args: ParsedArgs): Promise<void> {
|
||||
const environmentService = new EnvironmentService(args, process.execPath);
|
||||
const logService = new SpdLogService(RemoteExtensionLogFileName, environmentService.logsPath, getLogLevel(environmentService));
|
||||
const fileService = new FileService(logService);
|
||||
fileService.registerProvider(Schemas.file, new DiskFileSystemProvider(logService));
|
||||
|
||||
this.allowedRequestPaths.push(
|
||||
path.join(environmentService.userDataPath, "clp"), // Language packs.
|
||||
environmentService.extensionsPath,
|
||||
environmentService.builtinExtensionsPath,
|
||||
...environmentService.extraExtensionPaths,
|
||||
...environmentService.extraBuiltinExtensionPaths,
|
||||
);
|
||||
|
||||
this.ipc.registerChannel("logger", new LoggerChannel(logService));
|
||||
this.ipc.registerChannel(ExtensionHostDebugBroadcastChannel.ChannelName, new ExtensionHostDebugBroadcastChannel());
|
||||
|
||||
this.services.set(ILogService, logService);
|
||||
this.services.set(IEnvironmentService, environmentService);
|
||||
this.services.set(IConfigurationService, new SyncDescriptor(ConfigurationService, [environmentService.machineSettingsResource]));
|
||||
this.services.set(IRequestService, new SyncDescriptor(RequestService));
|
||||
this.services.set(IFileService, fileService);
|
||||
this.services.set(IProductService, { _serviceBrand: undefined, ...product });
|
||||
this.services.set(IExtensionGalleryService, new SyncDescriptor(ExtensionGalleryService));
|
||||
this.services.set(IExtensionManagementService, new SyncDescriptor(ExtensionManagementService));
|
||||
|
||||
if (!environmentService.args["disable-telemetry"]) {
|
||||
this.services.set(ITelemetryService, new SyncDescriptor(TelemetryService, [{
|
||||
appender: combinedAppender(
|
||||
new AppInsightsAppender("code-server", null, () => new TelemetryClient() as any, logService),
|
||||
new LogAppender(logService),
|
||||
),
|
||||
commonProperties: resolveCommonProperties(
|
||||
product.commit, product.codeServerVersion, await getMachineId(),
|
||||
[], environmentService.installSourcePath, "code-server",
|
||||
),
|
||||
piiPaths: this.allowedRequestPaths,
|
||||
} as ITelemetryServiceConfig]));
|
||||
} else {
|
||||
this.services.set(ITelemetryService, NullTelemetryService);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const instantiationService = new InstantiationService(this.services);
|
||||
this.services.set(ILocalizationsService, instantiationService.createInstance(LocalizationsService));
|
||||
this.services.set(INodeProxyService, instantiationService.createInstance(NodeProxyService));
|
||||
|
||||
instantiationService.invokeFunction(() => {
|
||||
instantiationService.createInstance(LogsDataCleaner);
|
||||
const telemetryService = this.services.get(ITelemetryService) as ITelemetryService;
|
||||
this.ipc.registerChannel("extensions", new ExtensionManagementChannel(
|
||||
this.services.get(IExtensionManagementService) as IExtensionManagementService,
|
||||
(context) => getUriTransformer(context.remoteAuthority),
|
||||
));
|
||||
this.ipc.registerChannel("remoteextensionsenvironment", new ExtensionEnvironmentChannel(
|
||||
environmentService, logService, telemetryService, this.options.connectionToken || "",
|
||||
));
|
||||
this.ipc.registerChannel("request", new RequestChannel(this.services.get(IRequestService) as IRequestService));
|
||||
this.ipc.registerChannel("telemetry", new TelemetryChannel(telemetryService));
|
||||
this.ipc.registerChannel("nodeProxy", new NodeProxyChannel(this.services.get(INodeProxyService) as INodeProxyService));
|
||||
this.ipc.registerChannel("localizations", <IServerChannel<any>>createChannelReceiver(this.services.get(ILocalizationsService) as ILocalizationsService));
|
||||
this.ipc.registerChannel("update", new UpdateChannel(instantiationService.createInstance(UpdateService)));
|
||||
this.ipc.registerChannel(REMOTE_FILE_SYSTEM_CHANNEL_NAME, new FileProviderChannel(environmentService, logService));
|
||||
resolve(new ErrorTelemetry(telemetryService));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: implement.
|
||||
*/
|
||||
private async getDebugPort(): Promise<number | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Since we can't pass TLS sockets to children, use this to proxy the socket
|
||||
* and pass a non-TLS socket.
|
||||
*/
|
||||
private createProxy = async (socket: net.Socket): Promise<net.Socket> => {
|
||||
if (!(socket instanceof tls.TLSSocket)) {
|
||||
return socket;
|
||||
}
|
||||
|
||||
await this.startProxyServer();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
listener.dispose();
|
||||
socket.destroy();
|
||||
proxy.destroy();
|
||||
reject(new Error("TLS socket proxy timed out"));
|
||||
}, this.proxyTimeout);
|
||||
|
||||
const listener = this._onProxyConnect.event((connection) => {
|
||||
connection.once("data", (data) => {
|
||||
if (!socket.destroyed && !proxy.destroyed && data.toString() === id) {
|
||||
clearTimeout(timeout);
|
||||
listener.dispose();
|
||||
[[proxy, socket], [socket, proxy]].forEach(([a, b]) => {
|
||||
a.pipe(b);
|
||||
a.on("error", () => b.destroy());
|
||||
a.on("close", () => b.destroy());
|
||||
a.on("end", () => b.end());
|
||||
});
|
||||
resolve(connection);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const id = generateUuid();
|
||||
const proxy = net.connect(this.proxyPipe);
|
||||
proxy.once("connect", () => proxy.write(id));
|
||||
});
|
||||
}
|
||||
|
||||
private async startProxyServer(): Promise<net.Server> {
|
||||
if (!this._proxyServer) {
|
||||
this._proxyServer = new Promise(async (resolve) => {
|
||||
this.proxyPipe = await this.findFreeSocketPath(this.proxyPipe);
|
||||
await mkdirp(tmpdir);
|
||||
await rimraf(this.proxyPipe);
|
||||
const proxyServer = net.createServer((p) => this._onProxyConnect.fire(p));
|
||||
proxyServer.once("listening", resolve);
|
||||
proxyServer.listen(this.proxyPipe);
|
||||
});
|
||||
}
|
||||
return this._proxyServer;
|
||||
}
|
||||
|
||||
private async findFreeSocketPath(basePath: string, maxTries: number = 100): Promise<string> {
|
||||
const canConnect = (path: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(path);
|
||||
socket.once("error", () => resolve(false));
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
let path = basePath;
|
||||
while (await canConnect(path) && i < maxTries) {
|
||||
path = `${basePath}-${++i}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the file path for Coder settings.
|
||||
*/
|
||||
private get settingsPath(): string {
|
||||
const environment = this.services.get(IEnvironmentService) as IEnvironmentService;
|
||||
return path.join(environment.userDataPath, "coder.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read settings from the file. On a failure return last known settings and
|
||||
* log a warning.
|
||||
*
|
||||
*/
|
||||
private async readSettings(): Promise<Settings> {
|
||||
try {
|
||||
const raw = (await util.promisify(fs.readFile)(this.settingsPath, "utf8")).trim();
|
||||
this.settings = raw ? JSON.parse(raw) : {};
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
(this.services.get(ILogService) as ILogService).warn(error.message);
|
||||
}
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings combined with current settings. On failure log a warning.
|
||||
*/
|
||||
private async writeSettings(newSettings: Partial<Settings>): Promise<void> {
|
||||
this.settings = { ...this.settings, ...newSettings };
|
||||
try {
|
||||
await util.promisify(fs.writeFile)(this.settingsPath, JSON.stringify(this.settings));
|
||||
} catch (error) {
|
||||
(this.services.get(ILogService) as ILogService).warn(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the file path for the heartbeat file.
|
||||
*/
|
||||
private get heartbeatPath(): string {
|
||||
const environment = this.services.get(IEnvironmentService) as IEnvironmentService;
|
||||
return path.join(environment.userDataPath, "heartbeat");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all online connections regardless of type.
|
||||
*/
|
||||
private get onlineConnections(): Connection[] {
|
||||
const online = <Connection[]>[];
|
||||
this.connections.forEach((connections) => {
|
||||
connections.forEach((connection) => {
|
||||
if (typeof connection.offline === "undefined") {
|
||||
online.push(connection);
|
||||
}
|
||||
});
|
||||
});
|
||||
return online;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 are
|
||||
* active connections. Failures are logged as warnings.
|
||||
*/
|
||||
private heartbeat(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastHeartbeat >= this.heartbeatInterval) {
|
||||
util.promisify(fs.writeFile)(this.heartbeatPath, "").catch((error) => {
|
||||
(this.services.get(ILogService) as ILogService).warn(error.message);
|
||||
});
|
||||
this.lastHeartbeat = now;
|
||||
clearTimeout(this.heartbeatTimer!); // We can clear undefined so ! is fine.
|
||||
this.heartbeatTimer = setTimeout(() => {
|
||||
if (this.onlineConnections.length > 0) {
|
||||
this.heartbeat();
|
||||
}
|
||||
}, this.heartbeatInterval);
|
||||
}
|
||||
}
|
||||
}
|
40
src/node/settings.ts
Normal file
40
src/node/settings.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import * as fs from "fs-extra"
|
||||
import { logger } from "@coder/logger"
|
||||
import { extend } from "./util"
|
||||
|
||||
export type Settings = { [key: string]: Settings | string | boolean | number }
|
||||
|
||||
/**
|
||||
* Provides read and write access to settings.
|
||||
*/
|
||||
export class SettingsProvider<T> {
|
||||
public constructor(private readonly settingsPath: string) {}
|
||||
|
||||
/**
|
||||
* Read settings from the file. On a failure return last known settings and
|
||||
* log a warning.
|
||||
*/
|
||||
public async read(): Promise<T> {
|
||||
try {
|
||||
const raw = (await fs.readFile(this.settingsPath, "utf8")).trim()
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
logger.warn(error.message)
|
||||
}
|
||||
}
|
||||
return {} as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings combined with current settings. On failure log a warning.
|
||||
* Objects will be merged and everything else will be replaced.
|
||||
*/
|
||||
public async write(settings: Partial<T>): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(this.settingsPath, JSON.stringify(extend(this.read(), settings)))
|
||||
} catch (error) {
|
||||
logger.warn(error.message)
|
||||
}
|
||||
}
|
||||
}
|
110
src/node/socket.ts
Normal file
110
src/node/socket.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import * as fs from "fs-extra"
|
||||
import * as net from "net"
|
||||
import * as path from "path"
|
||||
import * as tls from "tls"
|
||||
import { Emitter } from "../common/emitter"
|
||||
import { generateUuid } from "../common/util"
|
||||
import { tmpdir } from "./util"
|
||||
|
||||
/**
|
||||
* Provides a way to proxy a TLS socket. Can be used when you need to pass a
|
||||
* socket to a child process since you can't pass the TLS socket.
|
||||
*/
|
||||
export class SocketProxyProvider {
|
||||
private readonly onProxyConnect = new Emitter<net.Socket>()
|
||||
private proxyPipe = path.join(tmpdir, "tls-proxy")
|
||||
private _proxyServer?: Promise<net.Server>
|
||||
private readonly proxyTimeout = 5000
|
||||
|
||||
/**
|
||||
* Stop the proxy server.
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this._proxyServer) {
|
||||
this._proxyServer.then((server) => server.close())
|
||||
this._proxyServer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a socket proxy for TLS sockets. If it's not a TLS socket the
|
||||
* original socket is returned. This will spawn a proxy server on demand.
|
||||
*/
|
||||
public async createProxy(socket: net.Socket): Promise<net.Socket> {
|
||||
if (!(socket instanceof tls.TLSSocket)) {
|
||||
return socket
|
||||
}
|
||||
|
||||
await this.startProxyServer()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = generateUuid()
|
||||
const proxy = net.connect(this.proxyPipe)
|
||||
proxy.once("connect", () => proxy.write(id))
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
listener.dispose() // eslint-disable-line @typescript-eslint/no-use-before-define
|
||||
socket.destroy()
|
||||
proxy.destroy()
|
||||
reject(new Error("TLS socket proxy timed out"))
|
||||
}, this.proxyTimeout)
|
||||
|
||||
const listener = this.onProxyConnect.event((connection) => {
|
||||
connection.once("data", (data) => {
|
||||
if (!socket.destroyed && !proxy.destroyed && data.toString() === id) {
|
||||
clearTimeout(timeout)
|
||||
listener.dispose()
|
||||
;[
|
||||
[proxy, socket],
|
||||
[socket, proxy],
|
||||
].forEach(([a, b]) => {
|
||||
a.pipe(b)
|
||||
a.on("error", () => b.destroy())
|
||||
a.on("close", () => b.destroy())
|
||||
a.on("end", () => b.end())
|
||||
})
|
||||
resolve(connection)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async startProxyServer(): Promise<net.Server> {
|
||||
if (!this._proxyServer) {
|
||||
this._proxyServer = this.findFreeSocketPath(this.proxyPipe)
|
||||
.then((pipe) => {
|
||||
this.proxyPipe = pipe
|
||||
return Promise.all([fs.mkdirp(tmpdir), fs.remove(this.proxyPipe)])
|
||||
})
|
||||
.then(() => {
|
||||
return new Promise((resolve) => {
|
||||
const proxyServer = net.createServer((p) => this.onProxyConnect.emit(p))
|
||||
proxyServer.once("listening", () => resolve(proxyServer))
|
||||
proxyServer.listen(this.proxyPipe)
|
||||
})
|
||||
})
|
||||
}
|
||||
return this._proxyServer
|
||||
}
|
||||
|
||||
public async findFreeSocketPath(basePath: string, maxTries = 100): Promise<string> {
|
||||
const canConnect = (path: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(path)
|
||||
socket.once("error", () => resolve(false))
|
||||
socket.once("connect", () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let i = 0
|
||||
let path = basePath
|
||||
while ((await canConnect(path)) && i < maxTries) {
|
||||
path = `${basePath}-${++i}`
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
@ -1,141 +0,0 @@
|
||||
import * as cp from "child_process";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import { CancellationToken } from "vs/base/common/cancellation";
|
||||
import { URI } from "vs/base/common/uri";
|
||||
import * as pfs from "vs/base/node/pfs";
|
||||
import { IConfigurationService } from "vs/platform/configuration/common/configuration";
|
||||
import { IEnvironmentService } from "vs/platform/environment/common/environment";
|
||||
import { IFileService } from "vs/platform/files/common/files";
|
||||
import { ILogService } from "vs/platform/log/common/log";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { asJson, IRequestService } from "vs/platform/request/common/request";
|
||||
import { AvailableForDownload, State, UpdateType, StateType } from "vs/platform/update/common/update";
|
||||
import { AbstractUpdateService } from "vs/platform/update/electron-main/abstractUpdateService";
|
||||
import { ipcMain } from "vs/server/src/node/ipc";
|
||||
import { extract } from "vs/server/src/node/marketplace";
|
||||
import { tmpdir } from "vs/server/src/node/util";
|
||||
|
||||
interface IUpdate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class UpdateService extends AbstractUpdateService {
|
||||
_serviceBrand: any;
|
||||
|
||||
constructor(
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
@IEnvironmentService environmentService: IEnvironmentService,
|
||||
@IRequestService requestService: IRequestService,
|
||||
@ILogService logService: ILogService,
|
||||
@IFileService private readonly fileService: IFileService,
|
||||
) {
|
||||
super(null, configurationService, environmentService, requestService, logService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the currently installed version is the latest.
|
||||
*/
|
||||
public async isLatestVersion(latest?: IUpdate | null): Promise<boolean | undefined> {
|
||||
if (!latest) {
|
||||
latest = await this.getLatestVersion();
|
||||
}
|
||||
if (latest) {
|
||||
const latestMajor = parseInt(latest.name);
|
||||
const currentMajor = parseInt(product.codeServerVersion);
|
||||
// If these are invalid versions we can't compare meaningfully.
|
||||
return isNaN(latestMajor) || isNaN(currentMajor) ||
|
||||
// This can happen when there is a pre-release for a new major version.
|
||||
currentMajor > latestMajor ||
|
||||
// Otherwise assume that if it's not the same then we're out of date.
|
||||
latest.name === product.codeServerVersion;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected buildUpdateFeedUrl(quality: string): string {
|
||||
return `${product.updateUrl}/${quality}`;
|
||||
}
|
||||
|
||||
public async doQuitAndInstall(): Promise<void> {
|
||||
if (this.state.type === StateType.Ready) {
|
||||
ipcMain.relaunch(this.state.update.version);
|
||||
}
|
||||
}
|
||||
|
||||
protected async doCheckForUpdates(context: any): Promise<void> {
|
||||
this.setState(State.CheckingForUpdates(context));
|
||||
try {
|
||||
const update = await this.getLatestVersion();
|
||||
if (!update || await this.isLatestVersion(update)) {
|
||||
this.setState(State.Idle(UpdateType.Archive));
|
||||
} else {
|
||||
this.setState(State.AvailableForDownload({
|
||||
version: update.name,
|
||||
productVersion: update.name,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
this.onRequestError(error, !!context);
|
||||
}
|
||||
}
|
||||
|
||||
private async getLatestVersion(): Promise<IUpdate | null> {
|
||||
const data = await this.requestService.request({
|
||||
url: this.url,
|
||||
headers: { "User-Agent": "code-server" },
|
||||
}, CancellationToken.None);
|
||||
return asJson(data);
|
||||
}
|
||||
|
||||
protected async doDownloadUpdate(state: AvailableForDownload): Promise<void> {
|
||||
this.setState(State.Downloading(state.update));
|
||||
const target = os.platform();
|
||||
const releaseName = await this.buildReleaseName(state.update.version);
|
||||
const url = "https://github.com/cdr/code-server/releases/download/"
|
||||
+ `${state.update.version}/${releaseName}`
|
||||
+ `.${target === "darwin" ? "zip" : "tar.gz"}`;
|
||||
const downloadPath = path.join(tmpdir, `${state.update.version}-archive`);
|
||||
const extractPath = path.join(tmpdir, state.update.version);
|
||||
try {
|
||||
await pfs.mkdirp(tmpdir);
|
||||
const context = await this.requestService.request({ url }, CancellationToken.None, true);
|
||||
await this.fileService.writeFile(URI.file(downloadPath), context.stream);
|
||||
await extract(downloadPath, extractPath, undefined, CancellationToken.None);
|
||||
const newBinary = path.join(extractPath, releaseName, "code-server");
|
||||
if (!pfs.exists(newBinary)) {
|
||||
throw new Error("No code-server binary in extracted archive");
|
||||
}
|
||||
await pfs.unlink(process.argv[0]); // Must unlink first to avoid ETXTBSY.
|
||||
await pfs.move(newBinary, process.argv[0]);
|
||||
this.setState(State.Ready(state.update));
|
||||
} catch (error) {
|
||||
this.onRequestError(error, true);
|
||||
}
|
||||
await Promise.all([downloadPath, extractPath].map((p) => pfs.rimraf(p)));
|
||||
}
|
||||
|
||||
private onRequestError(error: Error, showNotification?: boolean): void {
|
||||
this.logService.error(error);
|
||||
this.setState(State.Idle(UpdateType.Archive, showNotification ? (error.message || error.toString()) : undefined));
|
||||
}
|
||||
|
||||
private async buildReleaseName(release: string): Promise<string> {
|
||||
let target: string = os.platform();
|
||||
if (target === "linux") {
|
||||
const result = await util.promisify(cp.exec)("ldd --version").catch((error) => ({
|
||||
stderr: error.message,
|
||||
stdout: "",
|
||||
}));
|
||||
if (/musl/.test(result.stderr) || /musl/.test(result.stdout)) {
|
||||
target = "alpine";
|
||||
}
|
||||
}
|
||||
let arch = os.arch();
|
||||
if (arch === "x64") {
|
||||
arch = "x86_64";
|
||||
}
|
||||
return `code-server${release}-${target}-${arch}`;
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
// This file is included via a regular Node require. I'm not sure how (or if)
|
||||
// we can write this in Typescript and have it compile to non-AMD syntax.
|
||||
module.exports = (remoteAuthority) => {
|
||||
return {
|
||||
transformIncoming: (uri) => {
|
||||
switch (uri.scheme) {
|
||||
case "vscode-remote": return { scheme: "file", path: uri.path };
|
||||
default: return uri;
|
||||
}
|
||||
},
|
||||
transformOutgoing: (uri) => {
|
||||
switch (uri.scheme) {
|
||||
case "file": return { scheme: "vscode-remote", authority: remoteAuthority, path: uri.path };
|
||||
default: return uri;
|
||||
}
|
||||
},
|
||||
transformOutgoingScheme: (scheme) => {
|
||||
switch (scheme) {
|
||||
case "file": return "vscode-remote";
|
||||
default: return scheme;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
316
src/node/util.ts
316
src/node/util.ts
@ -1,144 +1,216 @@
|
||||
import * as cp from "child_process";
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import * as rg from "vscode-ripgrep";
|
||||
import * as cp from "child_process"
|
||||
import * as crypto from "crypto"
|
||||
import * as fs from "fs-extra"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import * as util from "util"
|
||||
|
||||
import { getPathFromAmdModule } from "vs/base/common/amd";
|
||||
import { getMediaMime as vsGetMediaMime } from "vs/base/common/mime";
|
||||
import { extname } from "vs/base/common/path";
|
||||
import { URITransformer, IRawURITransformer } from "vs/base/common/uriIpc";
|
||||
import { mkdirp } from "vs/base/node/pfs";
|
||||
export const tmpdir = path.join(os.tmpdir(), "code-server")
|
||||
|
||||
export enum AuthType {
|
||||
Password = "password",
|
||||
None = "none",
|
||||
const getXdgDataDir = (): string => {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), "AppData/Local"), "code-server/Data")
|
||||
case "darwin":
|
||||
return path.join(
|
||||
process.env.XDG_DATA_HOME || path.join(os.homedir(), "Library/Application Support"),
|
||||
"code-server"
|
||||
)
|
||||
default:
|
||||
return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share"), "code-server")
|
||||
}
|
||||
}
|
||||
|
||||
export enum FormatType {
|
||||
Json = "json",
|
||||
export const xdgLocalDir = getXdgDataDir()
|
||||
|
||||
export const generateCertificate = async (): Promise<{ cert: string; certKey: string }> => {
|
||||
const paths = {
|
||||
cert: path.join(tmpdir, "self-signed.cert"),
|
||||
certKey: path.join(tmpdir, "self-signed.key"),
|
||||
}
|
||||
const checks = await Promise.all([fs.pathExists(paths.cert), fs.pathExists(paths.certKey)])
|
||||
if (!checks[0] || !checks[1]) {
|
||||
// Require on demand so openssl isn't required if you aren't going to
|
||||
// generate certificates.
|
||||
const pem = require("pem") as typeof import("pem")
|
||||
const certs = await new Promise<import("pem").CertificateCreationResult>((resolve, reject): void => {
|
||||
pem.createCertificate({ selfSigned: true }, (error, result) => {
|
||||
return error ? reject(error) : resolve(result)
|
||||
})
|
||||
})
|
||||
await fs.mkdirp(tmpdir)
|
||||
await Promise.all([fs.writeFile(paths.cert, certs.certificate), fs.writeFile(paths.certKey, certs.serviceKey)])
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
export const tmpdir = path.join(os.tmpdir(), "code-server");
|
||||
|
||||
export const generateCertificate = async (): Promise<{ cert: string, certKey: string }> => {
|
||||
const paths = {
|
||||
cert: path.join(tmpdir, "self-signed.cert"),
|
||||
certKey: path.join(tmpdir, "self-signed.key"),
|
||||
};
|
||||
|
||||
const exists = await Promise.all([
|
||||
util.promisify(fs.exists)(paths.cert),
|
||||
util.promisify(fs.exists)(paths.certKey),
|
||||
]);
|
||||
|
||||
if (!exists[0] || !exists[1]) {
|
||||
const pem = localRequire<typeof import("pem")>("pem/lib/pem");
|
||||
const certs = await new Promise<import("pem").CertificateCreationResult>((resolve, reject): void => {
|
||||
pem.createCertificate({ selfSigned: true }, (error, result) => {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
await mkdirp(tmpdir);
|
||||
await Promise.all([
|
||||
util.promisify(fs.writeFile)(paths.cert, certs.certificate),
|
||||
util.promisify(fs.writeFile)(paths.certKey, certs.serviceKey),
|
||||
]);
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
export const uriTransformerPath = getPathFromAmdModule(require, "vs/server/src/node/uriTransformer");
|
||||
export const getUriTransformer = (remoteAuthority: string): URITransformer => {
|
||||
const rawURITransformerFactory = <any>require.__$__nodeRequire(uriTransformerPath);
|
||||
const rawURITransformer = <IRawURITransformer>rawURITransformerFactory(remoteAuthority);
|
||||
return new URITransformer(rawURITransformer);
|
||||
};
|
||||
|
||||
export const generatePassword = async (length: number = 24): Promise<string> => {
|
||||
const buffer = Buffer.alloc(Math.ceil(length / 2));
|
||||
await util.promisify(crypto.randomFill)(buffer);
|
||||
return buffer.toString("hex").substring(0, length);
|
||||
};
|
||||
export const generatePassword = async (length = 24): Promise<string> => {
|
||||
const buffer = Buffer.alloc(Math.ceil(length / 2))
|
||||
await util.promisify(crypto.randomFill)(buffer)
|
||||
return buffer.toString("hex").substring(0, length)
|
||||
}
|
||||
|
||||
export const hash = (str: string): string => {
|
||||
return crypto.createHash("sha256").update(str).digest("hex");
|
||||
};
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(str)
|
||||
.digest("hex")
|
||||
}
|
||||
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
".aac": "audio/x-aac",
|
||||
".avi": "video/x-msvideo",
|
||||
".bmp": "image/bmp",
|
||||
".css": "text/css",
|
||||
".flv": "video/x-flv",
|
||||
".gif": "image/gif",
|
||||
".html": "text/html",
|
||||
".ico": "image/x-icon",
|
||||
".jpe": "image/jpg",
|
||||
".jpeg": "image/jpg",
|
||||
".jpg": "image/jpg",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".m1v": "video/mpeg",
|
||||
".m2a": "audio/mpeg",
|
||||
".m2v": "video/mpeg",
|
||||
".m3a": "audio/mpeg",
|
||||
".mid": "audio/midi",
|
||||
".midi": "audio/midi",
|
||||
".mk3d": "video/x-matroska",
|
||||
".mks": "video/x-matroska",
|
||||
".mkv": "video/x-matroska",
|
||||
".mov": "video/quicktime",
|
||||
".movie": "video/x-sgi-movie",
|
||||
".mp2": "audio/mpeg",
|
||||
".mp2a": "audio/mpeg",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mp4a": "audio/mp4",
|
||||
".mp4v": "video/mp4",
|
||||
".mpe": "video/mpeg",
|
||||
".mpeg": "video/mpeg",
|
||||
".mpg": "video/mpeg",
|
||||
".mpg4": "video/mp4",
|
||||
".mpga": "audio/mpeg",
|
||||
".oga": "audio/ogg",
|
||||
".ogg": "audio/ogg",
|
||||
".ogv": "video/ogg",
|
||||
".png": "image/png",
|
||||
".psd": "image/vnd.adobe.photoshop",
|
||||
".qt": "video/quicktime",
|
||||
".spx": "audio/ogg",
|
||||
".svg": "image/svg+xml",
|
||||
".tga": "image/x-tga",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".txt": "text/plain",
|
||||
".wav": "audio/x-wav",
|
||||
".wasm": "application/wasm",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".wma": "audio/x-ms-wma",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
".woff": "application/font-woff",
|
||||
}
|
||||
|
||||
export const getMediaMime = (filePath?: string): string => {
|
||||
return filePath && (vsGetMediaMime(filePath) || (<{[index: string]: string}>{
|
||||
".css": "text/css",
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
})[extname(filePath)]) || "text/plain";
|
||||
};
|
||||
return (filePath && mimeTypes[path.extname(filePath)]) || "text/plain"
|
||||
}
|
||||
|
||||
export const isWsl = async (): Promise<boolean> => {
|
||||
return process.platform === "linux"
|
||||
&& os.release().toLowerCase().indexOf("microsoft") !== -1
|
||||
|| (await util.promisify(fs.readFile)("/proc/version", "utf8"))
|
||||
.toLowerCase().indexOf("microsoft") !== -1;
|
||||
};
|
||||
return (
|
||||
(process.platform === "linux" &&
|
||||
os
|
||||
.release()
|
||||
.toLowerCase()
|
||||
.indexOf("microsoft") !== -1) ||
|
||||
(await fs.readFile("/proc/version", "utf8")).toLowerCase().indexOf("microsoft") !== -1
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try opening a URL using whatever the system has set for opening URLs.
|
||||
*/
|
||||
export const open = async (url: string): Promise<void> => {
|
||||
const args = <string[]>[];
|
||||
const options = <cp.SpawnOptions>{};
|
||||
const platform = await isWsl() ? "wsl" : process.platform;
|
||||
let command = platform === "darwin" ? "open" : "xdg-open";
|
||||
if (platform === "win32" || platform === "wsl") {
|
||||
command = platform === "wsl" ? "cmd.exe" : "cmd";
|
||||
args.push("/c", "start", '""', "/b");
|
||||
url = url.replace(/&/g, "^&");
|
||||
}
|
||||
const proc = cp.spawn(command, [...args, url], options);
|
||||
await new Promise((resolve, reject) => {
|
||||
proc.on("error", reject);
|
||||
proc.on("close", (code) => {
|
||||
return code !== 0
|
||||
? reject(new Error(`Failed to open with code ${code}`))
|
||||
: resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
const args = [] as string[]
|
||||
const options = {} as cp.SpawnOptions
|
||||
const platform = (await isWsl()) ? "wsl" : process.platform
|
||||
let command = platform === "darwin" ? "open" : "xdg-open"
|
||||
if (platform === "win32" || platform === "wsl") {
|
||||
command = platform === "wsl" ? "cmd.exe" : "cmd"
|
||||
args.push("/c", "start", '""', "/b")
|
||||
url = url.replace(/&/g, "^&")
|
||||
}
|
||||
const proc = cp.spawn(command, [...args, url], options)
|
||||
await new Promise((resolve, reject) => {
|
||||
proc.on("error", reject)
|
||||
proc.on("close", (code) => {
|
||||
return code !== 0 ? reject(new Error(`Failed to open with code ${code}`)) : resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract executables to the temporary directory. This is required since we
|
||||
* can't execute binaries stored within our binary.
|
||||
* Extract a file to the temporary directory and make it executable. This is
|
||||
* required since we can't execute binaries stored within our binary.
|
||||
*/
|
||||
export const unpackExecutables = async (): Promise<void> => {
|
||||
const rgPath = (rg as any).binaryRgPath;
|
||||
const destination = path.join(tmpdir, path.basename(rgPath || ""));
|
||||
if (rgPath && !(await util.promisify(fs.exists)(destination))) {
|
||||
await mkdirp(tmpdir);
|
||||
await util.promisify(fs.writeFile)(destination, await util.promisify(fs.readFile)(rgPath));
|
||||
await util.promisify(fs.chmod)(destination, "755");
|
||||
}
|
||||
};
|
||||
export const unpackExecutables = async (filePath: string): Promise<void> => {
|
||||
const destination = path.join(tmpdir, "binaries", path.basename(filePath))
|
||||
if (filePath && !(await util.promisify(fs.exists)(destination))) {
|
||||
await fs.mkdirp(tmpdir)
|
||||
await fs.writeFile(destination, await fs.readFile(filePath))
|
||||
await util.promisify(fs.chmod)(destination, "755")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For iterating over an enum's values.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const enumToArray = (t: any): string[] => {
|
||||
const values = <string[]>[];
|
||||
for (const k in t) {
|
||||
values.push(t[k]);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
export const buildAllowedMessage = (t: any): string => {
|
||||
const values = enumToArray(t);
|
||||
return `Allowed value${values.length === 1 ? " is" : "s are"} ${values.map((t) => `'${t}'`).join(", ")}`;
|
||||
};
|
||||
const values = [] as string[]
|
||||
for (const k in t) {
|
||||
values.push(t[k])
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a local module. This is necessary since VS Code's loader only looks
|
||||
* at the root for Node modules.
|
||||
* For displaying all allowed options in an enum.
|
||||
*/
|
||||
export const localRequire = <T>(modulePath: string): T => {
|
||||
return require.__$__nodeRequire(path.resolve(__dirname, "../../node_modules", modulePath));
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const buildAllowedMessage = (t: any): string => {
|
||||
const values = enumToArray(t)
|
||||
return `Allowed value${values.length === 1 ? " is" : "s are"} ${values.map((t) => `'${t}'`).join(", ")}`
|
||||
}
|
||||
|
||||
export const isObject = <T extends object>(obj: T): obj is T => {
|
||||
return !Array.isArray(obj) && typeof obj === "object" && obj !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend a with b and return a new object. Properties with objects will be
|
||||
* recursively merged while all other properties are just overwritten.
|
||||
*/
|
||||
export function extend<A, B>(a: A, b: B): A & B
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function extend(...args: any[]): any {
|
||||
const c = {} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
for (const obj of args) {
|
||||
if (!isObject(obj)) {
|
||||
continue
|
||||
}
|
||||
for (const key in obj) {
|
||||
c[key] = isObject(obj[key]) ? extend(c[key], obj[key]) : obj[key]
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove extra and trailing slashes in a URL.
|
||||
*/
|
||||
export const normalize = (url: string): string => {
|
||||
return url.replace(/\/\/+/g, "/").replace(/\/+$/, "")
|
||||
}
|
||||
|
59
src/node/vscode/README.md
Normal file
59
src/node/vscode/README.md
Normal file
@ -0,0 +1,59 @@
|
||||
Implementation of [VS Code](https://code.visualstudio.com/) remote/web for use
|
||||
in `code-server`.
|
||||
|
||||
## Docker
|
||||
|
||||
To debug Golang in VS Code using the
|
||||
[ms-vscode-go extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.Go),
|
||||
you need to add `--security-opt seccomp=unconfined` to your `docker run`
|
||||
arguments when launching code-server with Docker. See
|
||||
[#725](https://github.com/cdr/code-server/issues/725) for details.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Creating custom VS Code extensions and debugging them doesn't work.
|
||||
- Extension profiling and tips are currently disabled.
|
||||
|
||||
## Extensions
|
||||
|
||||
`code-server` does not provide access to the official
|
||||
[Visual Studio Marketplace](https://marketplace.visualstudio.com/vscode). Instead,
|
||||
Coder has created a custom extension marketplace that we manage for open-source
|
||||
extensions. If you want to use an extension with code-server that we do not have
|
||||
in our marketplace please look for a release in the extension’s repository,
|
||||
contact us to see if we have one in the works or, if you build an extension
|
||||
locally from open source, you can copy it to the `extensions` folder. If you
|
||||
build one locally from open-source please contribute it to the project and let
|
||||
us know so we can give you props! If you have your own custom marketplace, it is
|
||||
possible to point code-server to it by setting the `SERVICE_URL` and `ITEM_URL`
|
||||
environment variables.
|
||||
|
||||
## Development: upgrading VS Code
|
||||
|
||||
We patch VS Code to provide and fix some functionality. As the web portion of VS
|
||||
Code matures, we'll be able to shrink and maybe even entirely eliminate our
|
||||
patch. In the meantime, however, upgrading the VS Code version requires ensuring
|
||||
that the patch still applies and has the intended effects.
|
||||
|
||||
If functionality doesn't depend on code from VS Code then it should be moved
|
||||
into code-server otherwise it should be in the patch.
|
||||
|
||||
To generate a new patch, **stage all the changes** you want to be included in
|
||||
the patch in the VS Code source, then run `yarn patch:generate` in this
|
||||
directory.
|
||||
|
||||
Our changes include:
|
||||
|
||||
- Allow multiple extension directories (both user and built-in).
|
||||
- Modify the loader, websocket, webview, service worker, and asset requests to
|
||||
use the URL of the page as a base (and TLS if necessary for the websocket).
|
||||
- Send client-side telemetry through the server.
|
||||
- Make changing the display language work.
|
||||
- Make it possible for us to load code on the client.
|
||||
- Make extensions work in the browser.
|
||||
- Fix getting permanently disconnected when you sleep or hibernate for a while.
|
||||
- Make it possible to automatically update the binary.
|
||||
|
||||
## Future
|
||||
|
||||
- Run VS Code unit tests against our builds to ensure features work as expected.
|
204
src/node/vscode/server.ts
Normal file
204
src/node/vscode/server.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import { field, logger } from "@coder/logger"
|
||||
import * as cp from "child_process"
|
||||
import * as crypto from "crypto"
|
||||
import * as http from "http"
|
||||
import * as net from "net"
|
||||
import * as path from "path"
|
||||
import * as querystring from "querystring"
|
||||
import {
|
||||
CodeServerMessage,
|
||||
Settings,
|
||||
VscodeMessage,
|
||||
VscodeOptions,
|
||||
WorkbenchOptions,
|
||||
} from "../../../lib/vscode/src/vs/server/ipc"
|
||||
import { generateUuid } from "../../common/util"
|
||||
import { HttpProvider, HttpProviderOptions, HttpResponse } from "../http"
|
||||
import { SettingsProvider } from "../settings"
|
||||
import { xdgLocalDir } from "../util"
|
||||
|
||||
export class VscodeHttpProvider extends HttpProvider {
|
||||
private readonly serverRootPath: string
|
||||
private readonly vsRootPath: string
|
||||
private readonly settings = new SettingsProvider<Settings>(path.join(xdgLocalDir, "coder.json"))
|
||||
private _vscode?: Promise<cp.ChildProcess>
|
||||
private workbenchOptions?: WorkbenchOptions
|
||||
|
||||
public constructor(private readonly args: string[], options: HttpProviderOptions) {
|
||||
super(options)
|
||||
this.vsRootPath = path.resolve(this.rootPath, "lib/vscode")
|
||||
this.serverRootPath = path.join(this.vsRootPath, "out/vs/server")
|
||||
}
|
||||
|
||||
private async initialize(options: VscodeOptions): Promise<WorkbenchOptions> {
|
||||
const id = generateUuid()
|
||||
const vscode = await this.fork()
|
||||
|
||||
logger.debug("Setting up VS Code...")
|
||||
return new Promise<WorkbenchOptions>((resolve, reject) => {
|
||||
vscode.once("message", (message: VscodeMessage) => {
|
||||
logger.debug("Got message from VS Code", field("message", message))
|
||||
return message.type === "options" && message.id === id
|
||||
? resolve(message.options)
|
||||
: reject(new Error("Unexpected response during initialization"))
|
||||
})
|
||||
vscode.once("error", reject)
|
||||
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
|
||||
this.send({ type: "init", id, options }, vscode)
|
||||
})
|
||||
}
|
||||
|
||||
private fork(): Promise<cp.ChildProcess> {
|
||||
if (!this._vscode) {
|
||||
logger.debug("Forking VS Code...")
|
||||
const vscode = cp.fork(path.join(this.serverRootPath, "fork"))
|
||||
vscode.on("error", (error) => {
|
||||
logger.error(error.message)
|
||||
this._vscode = undefined
|
||||
})
|
||||
vscode.on("exit", (code) => {
|
||||
logger.error(`VS Code exited unexpectedly with code ${code}`)
|
||||
this._vscode = undefined
|
||||
})
|
||||
|
||||
this._vscode = new Promise((resolve, reject) => {
|
||||
vscode.once("message", (message: VscodeMessage) => {
|
||||
logger.debug("Got message from VS Code", field("message", message))
|
||||
return message.type === "ready"
|
||||
? resolve(vscode)
|
||||
: reject(new Error("Unexpected response waiting for ready response"))
|
||||
})
|
||||
vscode.once("error", reject)
|
||||
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
|
||||
})
|
||||
}
|
||||
|
||||
return this._vscode
|
||||
}
|
||||
|
||||
public async handleWebSocket(
|
||||
_base: string,
|
||||
_requestPath: string,
|
||||
query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage,
|
||||
socket: net.Socket
|
||||
): Promise<true> {
|
||||
if (!this.authenticated(request)) {
|
||||
throw new Error("not authenticated")
|
||||
}
|
||||
|
||||
// VS Code expects a raw socket. It will handle all the web socket frames.
|
||||
// We just need to handle the initial upgrade.
|
||||
// This magic value is specified by the websocket spec.
|
||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
const reply = crypto
|
||||
.createHash("sha1")
|
||||
.update(request.headers["sec-websocket-key"] + magic)
|
||||
.digest("base64")
|
||||
socket.write(
|
||||
[
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${reply}`,
|
||||
].join("\r\n") + "\r\n\r\n"
|
||||
)
|
||||
|
||||
const vscode = await this._vscode
|
||||
this.send({ type: "socket", query }, vscode, socket)
|
||||
return true
|
||||
}
|
||||
|
||||
private send(message: CodeServerMessage, vscode?: cp.ChildProcess, socket?: net.Socket): void {
|
||||
if (!vscode || vscode.killed) {
|
||||
throw new Error("vscode is not running")
|
||||
}
|
||||
vscode.send(message, socket)
|
||||
}
|
||||
|
||||
public async handleRequest(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage
|
||||
): Promise<HttpResponse | undefined> {
|
||||
this.ensureGet(request)
|
||||
switch (base) {
|
||||
case "/":
|
||||
if (!this.authenticated(request)) {
|
||||
return { redirect: "/login" }
|
||||
}
|
||||
return this.getRoot(request, query)
|
||||
case "/static": {
|
||||
switch (requestPath) {
|
||||
case "/out/vs/workbench/services/extensions/worker/extensionHostWorkerMain.js": {
|
||||
const response = await this.getUtf8Resource(this.vsRootPath, requestPath)
|
||||
response.content = response.content.replace(
|
||||
/{{COMMIT}}/g,
|
||||
this.workbenchOptions ? this.workbenchOptions.commit : ""
|
||||
)
|
||||
response.cache = true
|
||||
return response
|
||||
}
|
||||
}
|
||||
const response = await this.getResource(this.vsRootPath, requestPath)
|
||||
response.cache = true
|
||||
return response
|
||||
}
|
||||
case "/resource":
|
||||
case "/vscode-remote-resource":
|
||||
this.ensureAuthenticated(request)
|
||||
if (typeof query.path === "string") {
|
||||
return this.getResource(query.path)
|
||||
}
|
||||
break
|
||||
case "/tar":
|
||||
this.ensureAuthenticated(request)
|
||||
if (typeof query.path === "string") {
|
||||
return this.getTarredResource(query.path)
|
||||
}
|
||||
break
|
||||
case "/webview":
|
||||
this.ensureAuthenticated(request)
|
||||
if (/^\/vscode-resource/.test(requestPath)) {
|
||||
return this.getResource(requestPath.replace(/^\/vscode-resource(\/file)?/, ""))
|
||||
}
|
||||
return this.getResource(this.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", requestPath)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async getRoot(request: http.IncomingMessage, query: querystring.ParsedUrlQuery): Promise<HttpResponse> {
|
||||
const settings = await this.settings.read()
|
||||
const [response, options] = await Promise.all([
|
||||
this.getUtf8Resource(this.serverRootPath, "browser/workbench.html"),
|
||||
this.initialize({
|
||||
args: this.args,
|
||||
query,
|
||||
remoteAuthority: request.headers.host as string,
|
||||
settings,
|
||||
}),
|
||||
])
|
||||
|
||||
this.workbenchOptions = options
|
||||
|
||||
if (options.startPath) {
|
||||
this.settings.write({
|
||||
lastVisited: {
|
||||
path: options.startPath.path,
|
||||
workspace: options.startPath.workspace,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
content: response.content
|
||||
.replace(/{{COMMIT}}/g, options.commit)
|
||||
.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)}'`),
|
||||
}
|
||||
}
|
||||
}
|
223
src/node/wrapper.ts
Normal file
223
src/node/wrapper.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { logger, field } from "@coder/logger"
|
||||
import * as cp from "child_process"
|
||||
import { Emitter } from "../common/emitter"
|
||||
|
||||
interface HandshakeMessage {
|
||||
type: "handshake"
|
||||
}
|
||||
|
||||
interface RelaunchMessage {
|
||||
type: "relaunch"
|
||||
version: string
|
||||
}
|
||||
|
||||
export type Message = RelaunchMessage | HandshakeMessage
|
||||
|
||||
export class ProcessError extends Error {
|
||||
public constructor(message: string, public readonly code: number | undefined) {
|
||||
super(message)
|
||||
this.name = this.constructor.name
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we control when the process exits.
|
||||
*/
|
||||
const exit = process.exit
|
||||
process.exit = function(code?: number) {
|
||||
logger.warn(`process.exit() was prevented: ${code || "unknown code"}.`)
|
||||
} as (code?: number) => never
|
||||
|
||||
/**
|
||||
* Allows the wrapper and inner processes to communicate.
|
||||
*/
|
||||
export class IpcMain {
|
||||
private readonly _onMessage = new Emitter<Message>()
|
||||
public readonly onMessage = this._onMessage.event
|
||||
private readonly _onDispose = new Emitter<NodeJS.Signals | undefined>()
|
||||
public readonly onDispose = this._onDispose.event
|
||||
|
||||
public constructor(public readonly parentPid?: number) {
|
||||
process.on("SIGINT", () => this._onDispose.emit("SIGINT"))
|
||||
process.on("SIGTERM", () => this._onDispose.emit("SIGTERM"))
|
||||
process.on("exit", () => this._onDispose.emit(undefined))
|
||||
|
||||
this.onDispose((signal) => {
|
||||
// Remove listeners to avoid possibly triggering disposal again.
|
||||
process.removeAllListeners()
|
||||
|
||||
// Let any other handlers run first then exit.
|
||||
logger.debug(`${parentPid ? "inner process" : "wrapper"} ${process.pid} disposing`, field("code", signal))
|
||||
setTimeout(() => exit(0), 0)
|
||||
})
|
||||
|
||||
// Kill the inner process if the parent dies. This is for the case where the
|
||||
// parent process is forcefully terminated and cannot clean up.
|
||||
if (parentPid) {
|
||||
setInterval(() => {
|
||||
try {
|
||||
// process.kill throws an exception if the process doesn't exist.
|
||||
process.kill(parentPid, 0)
|
||||
} catch (_) {
|
||||
// Consider this an error since it should have been able to clean up
|
||||
// the child process unless it was forcefully killed.
|
||||
logger.error(`parent process ${parentPid} died`)
|
||||
this._onDispose.emit(undefined)
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
public handshake(child?: cp.ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = child || process
|
||||
const onMessage = (message: Message): void => {
|
||||
logger.debug(
|
||||
`${child ? "wrapper" : "inner process"} ${process.pid} received message from ${
|
||||
child ? child.pid : this.parentPid
|
||||
}`,
|
||||
field("message", message)
|
||||
)
|
||||
if (message.type === "handshake") {
|
||||
target.removeListener("message", onMessage)
|
||||
target.on("message", (msg) => this._onMessage.emit(msg))
|
||||
// The wrapper responds once the inner process starts the handshake.
|
||||
if (child) {
|
||||
if (!target.send) {
|
||||
throw new Error("child not spawned with IPC")
|
||||
}
|
||||
target.send({ type: "handshake" })
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
target.on("message", onMessage)
|
||||
if (child) {
|
||||
child.once("error", reject)
|
||||
child.once("exit", (code) => {
|
||||
reject(new ProcessError(`Unexpected exit with code ${code}`, code !== null ? code : undefined))
|
||||
})
|
||||
} else {
|
||||
// The inner process initiates the handshake.
|
||||
this.send({ type: "handshake" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public relaunch(version: string): void {
|
||||
this.send({ type: "relaunch", version })
|
||||
}
|
||||
|
||||
private send(message: Message): void {
|
||||
if (!process.send) {
|
||||
throw new Error("not spawned with IPC")
|
||||
}
|
||||
process.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcMain = new IpcMain(
|
||||
typeof process.env.CODE_SERVER_PARENT_PID !== "undefined" ? parseInt(process.env.CODE_SERVER_PARENT_PID) : undefined
|
||||
)
|
||||
|
||||
export interface WrapperOptions {
|
||||
maxMemory?: number
|
||||
nodeOptions?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a way to wrap a process for the purpose of updating the running
|
||||
* instance.
|
||||
*/
|
||||
export class WrapperProcess {
|
||||
private process?: cp.ChildProcess
|
||||
private started?: Promise<void>
|
||||
|
||||
public constructor(private currentVersion: string, private readonly options?: WrapperOptions) {
|
||||
ipcMain.onDispose(() => {
|
||||
if (this.process) {
|
||||
this.process.removeAllListeners()
|
||||
this.process.kill()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.onMessage(async (message) => {
|
||||
switch (message.type) {
|
||||
case "relaunch":
|
||||
logger.info(`Relaunching: ${this.currentVersion} -> ${message.version}`)
|
||||
this.currentVersion = message.version
|
||||
this.started = undefined
|
||||
if (this.process) {
|
||||
this.process.removeAllListeners()
|
||||
this.process.kill()
|
||||
}
|
||||
try {
|
||||
await this.start()
|
||||
} catch (error) {
|
||||
logger.error(error.message)
|
||||
exit(typeof error.code === "number" ? error.code : 1)
|
||||
}
|
||||
break
|
||||
default:
|
||||
logger.error(`Unrecognized message ${message}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public start(): Promise<void> {
|
||||
if (!this.started) {
|
||||
const child = this.spawn()
|
||||
logger.debug(`spawned inner process ${child.pid}`)
|
||||
this.started = ipcMain.handshake(child).then(() => {
|
||||
child.once("exit", (code) => {
|
||||
logger.debug(`inner process ${child.pid} exited unexpectedly`)
|
||||
exit(code || 0)
|
||||
})
|
||||
})
|
||||
this.process = child
|
||||
}
|
||||
return this.started
|
||||
}
|
||||
|
||||
private spawn(): cp.ChildProcess {
|
||||
// Flags to pass along to the Node binary.
|
||||
let nodeOptions = `${process.env.NODE_OPTIONS || ""} ${(this.options && this.options.nodeOptions) || ""}`
|
||||
if (!/max_old_space_size=(\d+)/g.exec(nodeOptions)) {
|
||||
nodeOptions += ` --max_old_space_size=${(this.options && this.options.maxMemory) || 2048}`
|
||||
}
|
||||
|
||||
return cp.fork(process.argv[1], process.argv.slice(2), {
|
||||
env: {
|
||||
...process.env,
|
||||
CODE_SERVER_PARENT_PID: process.pid.toString(),
|
||||
NODE_OPTIONS: nodeOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// // It's possible that the pipe has closed (for example if you run code-server
|
||||
// // --version | head -1). Assume that means we're done.
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.on("error", () => exit())
|
||||
}
|
||||
|
||||
export const wrap = (fn: () => Promise<void>): void => {
|
||||
if (ipcMain.parentPid) {
|
||||
ipcMain
|
||||
.handshake()
|
||||
.then(() => fn())
|
||||
.catch((error: ProcessError): void => {
|
||||
logger.error(error.message)
|
||||
exit(typeof error.code === "number" ? error.code : 1)
|
||||
})
|
||||
} else {
|
||||
const wrapper = new WrapperProcess(require("../../package.json").version)
|
||||
wrapper.start().catch((error) => {
|
||||
logger.error(error.message)
|
||||
exit(typeof error.code === "number" ? error.code : 1)
|
||||
})
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user