diff --git a/CHANGELOG.md b/CHANGELOG.md index 67957fe6..155a41e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,14 @@ ### Changed - Always enable verbose (`-v`) flag when a log directory is configured (`coder.proxyLogDir`). +- Replaced SSE paths with a one-way WebSocket and centralized creation on `OneWayWebSocket`, and unified socket handling. ### Added - Add support for CLI global flag configurations through the `coder.globalFlags` setting. +- Add logging for all REST and some WebSocket traffic, with REST verbosity configurable via `coder.httpClientLogLevel` (`none`, `basic`, `headers`, `body`). +- An Axios interceptor that tags each request with a UUID to correlate with its response and measure duration. +- Lifecycle logs for WebSocket creation, errors, and closures. ## [1.10.1](https://github.com/coder/vscode-coder/releases/tag/v1.10.1) 2025-08-13 diff --git a/package.json b/package.json index c3743cd4..6db957b0 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,23 @@ "items": { "type": "string" } + }, + "coder.httpClientLogLevel": { + "markdownDescription": "Controls the verbosity of HTTP client logging. This affects what details are logged for each HTTP request and response.", + "type": "string", + "enum": [ + "none", + "basic", + "headers", + "body" + ], + "markdownEnumDescriptions": [ + "Disables all HTTP client logging", + "Logs the request method, URL, length, and the response status code", + "Logs everything from *basic* plus sanitized request and response headers", + "Logs everything from *headers* plus request and response bodies (may include sensitive data)" + ], + "default": "basic" } } }, diff --git a/src/agentMetadataHelper.ts b/src/agentMetadataHelper.ts index d7c746ef..b3feb67a 100644 --- a/src/agentMetadataHelper.ts +++ b/src/agentMetadataHelper.ts @@ -1,13 +1,11 @@ -import { Api } from "coder/site/src/api/api"; import { WorkspaceAgent } from "coder/site/src/api/typesGenerated"; -import { EventSource } from "eventsource"; import * as vscode from "vscode"; -import { createStreamingFetchAdapter } from "./api"; import { AgentMetadataEvent, AgentMetadataEventSchemaArray, errToStr, -} from "./api-helper"; +} from "./api/api-helper"; +import { CodeApi } from "./api/codeApi"; export type AgentMetadataWatcher = { onChange: vscode.EventEmitter["event"]; @@ -17,21 +15,14 @@ export type AgentMetadataWatcher = { }; /** - * Opens an SSE connection to watch metadata for a given workspace agent. + * Opens a websocket connection to watch metadata for a given workspace agent. * Emits onChange when metadata updates or an error occurs. */ export function createAgentMetadataWatcher( agentId: WorkspaceAgent["id"], - restClient: Api, + client: CodeApi, ): AgentMetadataWatcher { - // TODO: Is there a better way to grab the url and token? - const url = restClient.getAxiosInstance().defaults.baseURL; - const metadataUrl = new URL( - `${url}/api/v2/workspaceagents/${agentId}/watch-metadata`, - ); - const eventSource = new EventSource(metadataUrl.toString(), { - fetch: createStreamingFetchAdapter(restClient.getAxiosInstance()), - }); + const socket = client.watchAgentMetadata(agentId); let disposed = false; const onChange = new vscode.EventEmitter(); @@ -39,16 +30,27 @@ export function createAgentMetadataWatcher( onChange: onChange.event, dispose: () => { if (!disposed) { - eventSource.close(); + socket.close(); disposed = true; } }, }; - eventSource.addEventListener("data", (event) => { + const handleError = (error: unknown) => { + watcher.error = error; + onChange.fire(null); + }; + + socket.addEventListener("message", (event) => { try { - const dataEvent = JSON.parse(event.data); - const metadata = AgentMetadataEventSchemaArray.parse(dataEvent); + if (event.parseError) { + handleError(event.parseError); + return; + } + + const metadata = AgentMetadataEventSchemaArray.parse( + event.parsedMessage.data, + ); // Overwrite metadata if it changed. if (JSON.stringify(watcher.metadata) !== JSON.stringify(metadata)) { @@ -56,8 +58,19 @@ export function createAgentMetadataWatcher( onChange.fire(null); } } catch (error) { - watcher.error = error; - onChange.fire(null); + handleError(error); + } + }); + + socket.addEventListener("error", handleError); + + socket.addEventListener("close", (event) => { + if (event.code !== 1000) { + handleError( + new Error( + `WebSocket closed unexpectedly: ${event.code} ${event.reason}`, + ), + ); } }); diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index 9c0022f0..00000000 --- a/src/api.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { AxiosInstance } from "axios"; -import { spawn } from "child_process"; -import { Api } from "coder/site/src/api/api"; -import { - ProvisionerJobLog, - Workspace, -} from "coder/site/src/api/typesGenerated"; -import { FetchLikeInit } from "eventsource"; -import fs from "fs/promises"; -import { ProxyAgent } from "proxy-agent"; -import * as vscode from "vscode"; -import * as ws from "ws"; -import { errToStr } from "./api-helper"; -import { CertificateError } from "./error"; -import { FeatureSet } from "./featureSet"; -import { getGlobalFlags } from "./globalFlags"; -import { getProxyForUrl } from "./proxy"; -import { Storage } from "./storage"; -import { expandPath } from "./util"; - -export const coderSessionTokenHeader = "Coder-Session-Token"; - -/** - * Return whether the API will need a token for authorization. - * If mTLS is in use (as specified by the cert or key files being set) then - * token authorization is disabled. Otherwise, it is enabled. - */ -export function needToken(): boolean { - const cfg = vscode.workspace.getConfiguration(); - const certFile = expandPath( - String(cfg.get("coder.tlsCertFile") ?? "").trim(), - ); - const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()); - return !certFile && !keyFile; -} - -/** - * Create a new agent based off the current settings. - */ -export async function createHttpAgent(): Promise { - const cfg = vscode.workspace.getConfiguration(); - const insecure = Boolean(cfg.get("coder.insecure")); - const certFile = expandPath( - String(cfg.get("coder.tlsCertFile") ?? "").trim(), - ); - const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()); - const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim()); - const altHost = expandPath(String(cfg.get("coder.tlsAltHost") ?? "").trim()); - - return new ProxyAgent({ - // Called each time a request is made. - getProxyForUrl: (url: string) => { - const cfg = vscode.workspace.getConfiguration(); - return getProxyForUrl( - url, - cfg.get("http.proxy"), - cfg.get("coder.proxyBypass"), - ); - }, - cert: certFile === "" ? undefined : await fs.readFile(certFile), - key: keyFile === "" ? undefined : await fs.readFile(keyFile), - ca: caFile === "" ? undefined : await fs.readFile(caFile), - servername: altHost === "" ? undefined : altHost, - // rejectUnauthorized defaults to true, so we need to explicitly set it to - // false if we want to allow self-signed certificates. - rejectUnauthorized: !insecure, - }); -} - -/** - * Create an sdk instance using the provided URL and token and hook it up to - * configuration. The token may be undefined if some other form of - * authentication is being used. - */ -export function makeCoderSdk( - baseUrl: string, - token: string | undefined, - storage: Storage, -): Api { - const restClient = new Api(); - restClient.setHost(baseUrl); - if (token) { - restClient.setSessionToken(token); - } - - restClient.getAxiosInstance().interceptors.request.use(async (config) => { - // Add headers from the header command. - Object.entries(await storage.getHeaders(baseUrl)).forEach( - ([key, value]) => { - config.headers[key] = value; - }, - ); - - // Configure proxy and TLS. - // Note that by default VS Code overrides the agent. To prevent this, set - // `http.proxySupport` to `on` or `off`. - const agent = await createHttpAgent(); - config.httpsAgent = agent; - config.httpAgent = agent; - config.proxy = false; - - return config; - }); - - // Wrap certificate errors. - restClient.getAxiosInstance().interceptors.response.use( - (r) => r, - async (err) => { - throw await CertificateError.maybeWrap(err, baseUrl, storage.output); - }, - ); - - return restClient; -} - -/** - * Creates a fetch adapter using an Axios instance that returns streaming responses. - * This can be used with APIs that accept fetch-like interfaces. - */ -export function createStreamingFetchAdapter(axiosInstance: AxiosInstance) { - return async (url: string | URL, init?: FetchLikeInit) => { - const urlStr = url.toString(); - - const response = await axiosInstance.request({ - url: urlStr, - signal: init?.signal, - headers: init?.headers as Record, - responseType: "stream", - validateStatus: () => true, // Don't throw on any status code - }); - const stream = new ReadableStream({ - start(controller) { - response.data.on("data", (chunk: Buffer) => { - controller.enqueue(chunk); - }); - - response.data.on("end", () => { - controller.close(); - }); - - response.data.on("error", (err: Error) => { - controller.error(err); - }); - }, - - cancel() { - response.data.destroy(); - return Promise.resolve(); - }, - }); - - return { - body: { - getReader: () => stream.getReader(), - }, - url: urlStr, - status: response.status, - redirected: response.request.res.responseUrl !== urlStr, - headers: { - get: (name: string) => { - const value = response.headers[name.toLowerCase()]; - return value === undefined ? null : String(value); - }, - }, - }; - }; -} - -/** - * Start or update a workspace and return the updated workspace. - */ -export async function startWorkspaceIfStoppedOrFailed( - restClient: Api, - globalConfigDir: string, - binPath: string, - workspace: Workspace, - writeEmitter: vscode.EventEmitter, - featureSet: FeatureSet, -): Promise { - // Before we start a workspace, we make an initial request to check it's not already started - const updatedWorkspace = await restClient.getWorkspace(workspace.id); - - if (!["stopped", "failed"].includes(updatedWorkspace.latest_build.status)) { - return updatedWorkspace; - } - - return new Promise((resolve, reject) => { - const startArgs = [ - ...getGlobalFlags(vscode.workspace.getConfiguration(), globalConfigDir), - "start", - "--yes", - workspace.owner_name + "/" + workspace.name, - ]; - if (featureSet.buildReason) { - startArgs.push(...["--reason", "vscode_connection"]); - } - - const startProcess = spawn(binPath, startArgs, { shell: true }); - - startProcess.stdout.on("data", (data: Buffer) => { - data - .toString() - .split(/\r*\n/) - .forEach((line: string) => { - if (line !== "") { - writeEmitter.fire(line.toString() + "\r\n"); - } - }); - }); - - let capturedStderr = ""; - startProcess.stderr.on("data", (data: Buffer) => { - data - .toString() - .split(/\r*\n/) - .forEach((line: string) => { - if (line !== "") { - writeEmitter.fire(line.toString() + "\r\n"); - capturedStderr += line.toString() + "\n"; - } - }); - }); - - startProcess.on("close", (code: number) => { - if (code === 0) { - resolve(restClient.getWorkspace(workspace.id)); - } else { - let errorText = `"${startArgs.join(" ")}" exited with code ${code}`; - if (capturedStderr !== "") { - errorText += `: ${capturedStderr}`; - } - reject(new Error(errorText)); - } - }); - }); -} - -/** - * Wait for the latest build to finish while streaming logs to the emitter. - * - * Once completed, fetch the workspace again and return it. - */ -export async function waitForBuild( - restClient: Api, - writeEmitter: vscode.EventEmitter, - workspace: Workspace, -): Promise { - const baseUrlRaw = restClient.getAxiosInstance().defaults.baseURL; - if (!baseUrlRaw) { - throw new Error("No base URL set on REST client"); - } - - // This fetches the initial bunch of logs. - const logs = await restClient.getWorkspaceBuildLogs( - workspace.latest_build.id, - ); - logs.forEach((log) => writeEmitter.fire(log.output + "\r\n")); - - // This follows the logs for new activity! - // TODO: watchBuildLogsByBuildId exists, but it uses `location`. - // Would be nice if we could use it here. - let path = `/api/v2/workspacebuilds/${workspace.latest_build.id}/logs?follow=true`; - if (logs.length) { - path += `&after=${logs[logs.length - 1].id}`; - } - - const agent = await createHttpAgent(); - await new Promise((resolve, reject) => { - try { - const baseUrl = new URL(baseUrlRaw); - const proto = baseUrl.protocol === "https:" ? "wss:" : "ws:"; - const socketUrlRaw = `${proto}//${baseUrl.host}${path}`; - const token = restClient.getAxiosInstance().defaults.headers.common[ - coderSessionTokenHeader - ] as string | undefined; - const socket = new ws.WebSocket(new URL(socketUrlRaw), { - agent: agent, - followRedirects: true, - headers: token - ? { - [coderSessionTokenHeader]: token, - } - : undefined, - }); - socket.binaryType = "nodebuffer"; - socket.on("message", (data) => { - const buf = data as Buffer; - const log = JSON.parse(buf.toString()) as ProvisionerJobLog; - writeEmitter.fire(log.output + "\r\n"); - }); - socket.on("error", (error) => { - reject( - new Error( - `Failed to watch workspace build using ${socketUrlRaw}: ${errToStr(error, "no further details")}`, - ), - ); - }); - socket.on("close", () => { - resolve(); - }); - } catch (error) { - // If this errors, it is probably a malformed URL. - reject( - new Error( - `Failed to watch workspace build on ${baseUrlRaw}: ${errToStr(error, "no further details")}`, - ), - ); - } - }); - - writeEmitter.fire("Build complete\r\n"); - const updatedWorkspace = await restClient.getWorkspace(workspace.id); - writeEmitter.fire( - `Workspace is now ${updatedWorkspace.latest_build.status}\r\n`, - ); - return updatedWorkspace; -} diff --git a/src/api-helper.ts b/src/api/api-helper.ts similarity index 87% rename from src/api-helper.ts rename to src/api/api-helper.ts index 6526b34d..7b41f46c 100644 --- a/src/api-helper.ts +++ b/src/api/api-helper.ts @@ -7,6 +7,9 @@ import { import { ErrorEvent } from "eventsource"; import { z } from "zod"; +/** + * Convert various error types to readable strings + */ export function errToStr( error: unknown, def: string = "No error message provided", @@ -27,6 +30,13 @@ export function errToStr( return def; } +/** + * Create workspace owner/name identifier + */ +export function createWorkspaceIdentifier(workspace: Workspace): string { + return `${workspace.owner_name}/${workspace.name}`; +} + export function extractAllAgents( workspaces: readonly Workspace[], ): WorkspaceAgent[] { diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 00000000..2cb4e91e --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,50 @@ +import fs from "fs"; +import { ProxyAgent } from "proxy-agent"; +import { type WorkspaceConfiguration } from "vscode"; +import { getProxyForUrl } from "../proxy"; +import { expandPath } from "../util"; + +/** + * Return whether the API will need a token for authorization. + * If mTLS is in use (as specified by the cert or key files being set) then + * token authorization is disabled. Otherwise, it is enabled. + */ +export function needToken(cfg: WorkspaceConfiguration): boolean { + const certFile = expandPath( + String(cfg.get("coder.tlsCertFile") ?? "").trim(), + ); + const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()); + return !certFile && !keyFile; +} + +/** + * Create a new HTTP agent based on the current VS Code settings. + * Configures proxy, TLS certificates, and security options. + */ +export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent { + const insecure = Boolean(cfg.get("coder.insecure")); + const certFile = expandPath( + String(cfg.get("coder.tlsCertFile") ?? "").trim(), + ); + const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()); + const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim()); + const altHost = expandPath(String(cfg.get("coder.tlsAltHost") ?? "").trim()); + + return new ProxyAgent({ + // Called each time a request is made. + getProxyForUrl: (url: string) => { + return getProxyForUrl( + url, + cfg.get("http.proxy"), + cfg.get("coder.proxyBypass"), + ); + }, + cert: certFile === "" ? undefined : fs.readFileSync(certFile), + key: keyFile === "" ? undefined : fs.readFileSync(keyFile), + ca: caFile === "" ? undefined : fs.readFileSync(caFile), + servername: altHost === "" ? undefined : altHost, + // rejectUnauthorized defaults to true, so we need to explicitly set it to + // false if we want to allow self-signed certificates. + rejectUnauthorized: !insecure, + }); +} diff --git a/src/api/codeApi.ts b/src/api/codeApi.ts new file mode 100644 index 00000000..511de144 --- /dev/null +++ b/src/api/codeApi.ts @@ -0,0 +1,248 @@ +import { AxiosInstance } from "axios"; +import { Api } from "coder/site/src/api/api"; +import { + GetInboxNotificationResponse, + ProvisionerJobLog, + ServerSentEvent, + Workspace, + WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; +import { type WorkspaceConfiguration } from "vscode"; +import { ClientOptions } from "ws"; +import { CertificateError } from "../error"; +import { getHeaderCommand, getHeaders } from "../headers"; +import { + createRequestMeta, + logRequest, + logRequestError, + logResponse, +} from "../logging/httpLogger"; +import { Logger } from "../logging/logger"; +import { RequestConfigWithMeta, HttpClientLogLevel } from "../logging/types"; +import { WsLogger } from "../logging/wsLogger"; +import { OneWayWebSocket } from "../websocket/oneWayWebSocket"; +import { createHttpAgent } from "./auth"; + +const coderSessionTokenHeader = "Coder-Session-Token"; + +type WorkspaceConfigurationProvider = () => WorkspaceConfiguration; + +/** + * Unified API class that includes both REST API methods from the base Api class + * and WebSocket methods for real-time functionality. + */ +export class CodeApi extends Api { + private constructor( + private readonly output: Logger, + private readonly configProvider: WorkspaceConfigurationProvider, + ) { + super(); + } + + /** + * Create a new CodeApi instance with the provided configuration. + * Automatically sets up logging interceptors and certificate handling. + */ + static create( + baseUrl: string, + token: string | undefined, + output: Logger, + configProvider: WorkspaceConfigurationProvider, + ): CodeApi { + const client = new CodeApi(output, configProvider); + client.setHost(baseUrl); + if (token) { + client.setSessionToken(token); + } + + setupInterceptors(client, baseUrl, output, configProvider); + return client; + } + + watchInboxNotifications = ( + watchTemplates: string[], + watchTargets: string[], + options?: ClientOptions, + ) => { + return this.createWebSocket({ + apiRoute: "/api/v2/notifications/inbox/watch", + searchParams: { + format: "plaintext", + templates: watchTemplates.join(","), + targets: watchTargets.join(","), + }, + options, + }); + }; + + watchWorkspace = (workspace: Workspace, options?: ClientOptions) => { + return this.createWebSocket({ + apiRoute: `/api/v2/workspaces/${workspace.id}/watch-ws`, + options, + }); + }; + + watchAgentMetadata = ( + agentId: WorkspaceAgent["id"], + options?: ClientOptions, + ) => { + return this.createWebSocket({ + apiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata-ws`, + options, + }); + }; + + watchBuildLogsByBuildId = (buildId: string, logs: ProvisionerJobLog[]) => { + const searchParams = new URLSearchParams({ follow: "true" }); + if (logs.length) { + searchParams.append("after", logs[logs.length - 1].id.toString()); + } + + const socket = this.createWebSocket({ + apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`, + searchParams, + }); + + return socket; + }; + + private createWebSocket(configs: { + apiRoute: string; + protocols?: string | string[]; + searchParams?: Record | URLSearchParams; + options?: ClientOptions; + }) { + const baseUrlRaw = this.getAxiosInstance().defaults.baseURL; + if (!baseUrlRaw) { + throw new Error("No base URL set on REST client"); + } + + const baseUrl = new URL(baseUrlRaw); + const token = this.getAxiosInstance().defaults.headers.common[ + coderSessionTokenHeader + ] as string | undefined; + + const httpAgent = createHttpAgent(this.configProvider()); + const webSocket = new OneWayWebSocket({ + location: baseUrl, + ...configs, + options: { + agent: httpAgent, + followRedirects: true, + headers: { + ...(token ? { [coderSessionTokenHeader]: token } : {}), + ...configs.options?.headers, + }, + ...configs.options, + }, + }); + + const wsUrl = new URL(webSocket.url); + const pathWithQuery = wsUrl.pathname + wsUrl.search; + const wsLogger = new WsLogger(this.output, pathWithQuery); + wsLogger.logConnecting(); + + webSocket.addEventListener("open", () => { + wsLogger.logOpen(); + }); + + webSocket.addEventListener("message", (event) => { + wsLogger.logMessage(event.sourceEvent.data); + }); + + webSocket.addEventListener("close", (event) => { + wsLogger.logClose(event.code, event.reason); + }); + + webSocket.addEventListener("error", (event) => { + wsLogger.logError(event.error, event.message); + }); + + return webSocket; + } +} + +/** + * Set up logging and request interceptors for the CodeApi instance. + */ +function setupInterceptors( + client: CodeApi, + baseUrl: string, + output: Logger, + configProvider: WorkspaceConfigurationProvider, +): void { + addLoggingInterceptors(client.getAxiosInstance(), output, configProvider); + + client.getAxiosInstance().interceptors.request.use(async (config) => { + const headers = await getHeaders( + baseUrl, + getHeaderCommand(configProvider()), + output, + ); + // Add headers from the header command. + Object.entries(headers).forEach(([key, value]) => { + config.headers[key] = value; + }); + + // Configure proxy and TLS. + // Note that by default VS Code overrides the agent. To prevent this, set + // `http.proxySupport` to `on` or `off`. + const agent = createHttpAgent(configProvider()); + config.httpsAgent = agent; + config.httpAgent = agent; + config.proxy = false; + + return config; + }); + + // Wrap certificate errors. + client.getAxiosInstance().interceptors.response.use( + (r) => r, + async (err) => { + throw await CertificateError.maybeWrap(err, baseUrl, output); + }, + ); +} + +function addLoggingInterceptors( + client: AxiosInstance, + logger: Logger, + configProvider: WorkspaceConfigurationProvider, +) { + client.interceptors.request.use( + (config) => { + const meta = createRequestMeta(); + (config as RequestConfigWithMeta).metadata = meta; + logRequest(logger, meta.requestId, config, getLogLevel(configProvider())); + return config; + }, + (error: unknown) => { + logRequestError(logger, error); + return Promise.reject(error); + }, + ); + + client.interceptors.response.use( + (response) => { + const meta = (response.config as RequestConfigWithMeta).metadata; + if (meta) { + logResponse(logger, meta, response, getLogLevel(configProvider())); + } + return response; + }, + (error: unknown) => { + logRequestError(logger, error); + return Promise.reject(error); + }, + ); +} + +function getLogLevel(cfg: WorkspaceConfiguration): HttpClientLogLevel { + const logLevelStr = cfg + .get( + "coder.httpClientLogLevel", + HttpClientLogLevel[HttpClientLogLevel.BASIC], + ) + .toUpperCase(); + return HttpClientLogLevel[logLevelStr as keyof typeof HttpClientLogLevel]; +} diff --git a/src/api/workspace.ts b/src/api/workspace.ts new file mode 100644 index 00000000..0ee0c02e --- /dev/null +++ b/src/api/workspace.ts @@ -0,0 +1,127 @@ +import { spawn } from "child_process"; +import { Api } from "coder/site/src/api/api"; +import { Workspace } from "coder/site/src/api/typesGenerated"; +import * as vscode from "vscode"; +import { FeatureSet } from "../featureSet"; +import { getGlobalFlags } from "../globalFlags"; +import { errToStr, createWorkspaceIdentifier } from "./api-helper"; +import { CodeApi } from "./codeApi"; + +/** + * Start or update a workspace and return the updated workspace. + */ +export async function startWorkspaceIfStoppedOrFailed( + restClient: Api, + globalConfigDir: string, + binPath: string, + workspace: Workspace, + writeEmitter: vscode.EventEmitter, + featureSet: FeatureSet, +): Promise { + // Before we start a workspace, we make an initial request to check it's not already started + const updatedWorkspace = await restClient.getWorkspace(workspace.id); + + if (!["stopped", "failed"].includes(updatedWorkspace.latest_build.status)) { + return updatedWorkspace; + } + + return new Promise((resolve, reject) => { + const startArgs = [ + ...getGlobalFlags(vscode.workspace.getConfiguration(), globalConfigDir), + "start", + "--yes", + createWorkspaceIdentifier(workspace), + ]; + if (featureSet.buildReason) { + startArgs.push(...["--reason", "vscode_connection"]); + } + + const startProcess = spawn(binPath, startArgs, { shell: true }); + + startProcess.stdout.on("data", (data: Buffer) => { + data + .toString() + .split(/\r*\n/) + .forEach((line: string) => { + if (line !== "") { + writeEmitter.fire(line.toString() + "\r\n"); + } + }); + }); + + let capturedStderr = ""; + startProcess.stderr.on("data", (data: Buffer) => { + data + .toString() + .split(/\r*\n/) + .forEach((line: string) => { + if (line !== "") { + writeEmitter.fire(line.toString() + "\r\n"); + capturedStderr += line.toString() + "\n"; + } + }); + }); + + startProcess.on("close", (code: number) => { + if (code === 0) { + resolve(restClient.getWorkspace(workspace.id)); + } else { + let errorText = `"${startArgs.join(" ")}" exited with code ${code}`; + if (capturedStderr !== "") { + errorText += `: ${capturedStderr}`; + } + reject(new Error(errorText)); + } + }); + }); +} + +/** + * Wait for the latest build to finish while streaming logs to the emitter. + * + * Once completed, fetch the workspace again and return it. + */ +export async function waitForBuild( + client: CodeApi, + writeEmitter: vscode.EventEmitter, + workspace: Workspace, +): Promise { + // This fetches the initial bunch of logs. + const logs = await client.getWorkspaceBuildLogs(workspace.latest_build.id); + logs.forEach((log) => writeEmitter.fire(log.output + "\r\n")); + + await new Promise((resolve, reject) => { + const socket = client.watchBuildLogsByBuildId( + workspace.latest_build.id, + logs, + ); + + socket.addEventListener("message", (data) => { + if (data.parseError) { + writeEmitter.fire( + errToStr(data.parseError, "Failed to parse message") + "\r\n", + ); + } else { + writeEmitter.fire(data.parsedMessage.output + "\r\n"); + } + }); + + socket.addEventListener("error", (error) => { + const baseUrlRaw = client.getAxiosInstance().defaults.baseURL; + return reject( + new Error( + `Failed to watch workspace build on ${baseUrlRaw}: ${errToStr(error, "no further details")}`, + ), + ); + }); + + socket.addEventListener("close", () => resolve()); + }); + + writeEmitter.fire("Build complete\r\n"); + const updatedWorkspace = await client.getWorkspace(workspace.id); + writeEmitter.fire( + `Workspace is now ${updatedWorkspace.latest_build.status}\r\n`, + ); + return updatedWorkspace; +} diff --git a/src/commands.ts b/src/commands.ts index 2e4ba705..9f99f4c9 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -7,8 +7,9 @@ import { } from "coder/site/src/api/typesGenerated"; import path from "node:path"; import * as vscode from "vscode"; -import { makeCoderSdk, needToken } from "./api"; -import { extractAgents } from "./api-helper"; +import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; +import { needToken } from "./api/auth"; +import { CodeApi } from "./api/codeApi"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; import { Storage } from "./storage"; @@ -239,10 +240,12 @@ export class Commands { token: string, isAutologin: boolean, ): Promise<{ user: User; token: string } | null> { - const restClient = makeCoderSdk(url, token, this.storage); - if (!needToken()) { + const client = CodeApi.create(url, token, this.storage.output, () => + vscode.workspace.getConfiguration(), + ); + if (!needToken(vscode.workspace.getConfiguration())) { try { - const user = await restClient.getAuthenticatedUser(); + const user = await client.getAuthenticatedUser(); // For non-token auth, we write a blank token since the `vscodessh` // command currently always requires a token file. return { token: "", user }; @@ -283,9 +286,9 @@ export class Commands { value: token || (await this.storage.getSessionToken()), ignoreFocusOut: true, validateInput: async (value) => { - restClient.setSessionToken(value); + client.setSessionToken(value); try { - user = await restClient.getAuthenticatedUser(); + user = await client.getAuthenticatedUser(); } catch (err) { // For certificate errors show both a notification and add to the // text under the input box, since users sometimes miss the @@ -398,14 +401,13 @@ export class Commands { */ public async navigateToWorkspace(item: OpenableTreeItem) { if (item) { - const uri = - this.storage.getUrl() + - `/@${item.workspace.owner_name}/${item.workspace.name}`; + const workspaceId = createWorkspaceIdentifier(item.workspace); + const uri = this.storage.getUrl() + `/@${workspaceId}`; await vscode.commands.executeCommand("vscode.open", uri); } else if (this.workspace && this.workspaceRestClient) { const baseUrl = this.workspaceRestClient.getAxiosInstance().defaults.baseURL; - const uri = `${baseUrl}/@${this.workspace.owner_name}/${this.workspace.name}`; + const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}`; await vscode.commands.executeCommand("vscode.open", uri); } else { vscode.window.showInformationMessage("No workspace found."); @@ -422,14 +424,13 @@ export class Commands { */ public async navigateToWorkspaceSettings(item: OpenableTreeItem) { if (item) { - const uri = - this.storage.getUrl() + - `/@${item.workspace.owner_name}/${item.workspace.name}/settings`; + const workspaceId = createWorkspaceIdentifier(item.workspace); + const uri = this.storage.getUrl() + `/@${workspaceId}/settings`; await vscode.commands.executeCommand("vscode.open", uri); } else if (this.workspace && this.workspaceRestClient) { const baseUrl = this.workspaceRestClient.getAxiosInstance().defaults.baseURL; - const uri = `${baseUrl}/@${this.workspace.owner_name}/${this.workspace.name}/settings`; + const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}/settings`; await vscode.commands.executeCommand("vscode.open", uri); } else { vscode.window.showInformationMessage("No workspace found."); @@ -630,7 +631,7 @@ export class Commands { { useCustom: true, modal: true, - detail: `Update ${this.workspace.owner_name}/${this.workspace.name} to the latest version?\n\nUpdating will restart your workspace which stops any running processes and may result in the loss of unsaved work.`, + detail: `Update ${createWorkspaceIdentifier(this.workspace)} to the latest version?\n\nUpdating will restart your workspace which stops any running processes and may result in the loss of unsaved work.`, }, "Update", "Cancel", diff --git a/src/error.test.ts b/src/error.test.ts index 4bbb9395..2d591d89 100644 --- a/src/error.test.ts +++ b/src/error.test.ts @@ -4,7 +4,7 @@ import https from "https"; import * as path from "path"; import { afterAll, beforeAll, it, expect, vi } from "vitest"; import { CertificateError, X509_ERR, X509_ERR_CODE } from "./error"; -import { Logger } from "./logger"; +import { Logger } from "./logging/logger"; // Before each test we make a request to sanity check that we really get the // error we are expecting, then we run it through CertificateError. diff --git a/src/error.ts b/src/error.ts index 5fa07294..994b5910 100644 --- a/src/error.ts +++ b/src/error.ts @@ -3,7 +3,7 @@ import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; import * as forge from "node-forge"; import * as tls from "tls"; import * as vscode from "vscode"; -import { Logger } from "./logger"; +import { Logger } from "./logging/logger"; // X509_ERR_CODE represents error codes as returned from BoringSSL/OpenSSL. export enum X509_ERR_CODE { diff --git a/src/extension.ts b/src/extension.ts index e765ee1b..58d0d523 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,8 +3,9 @@ import axios, { isAxiosError } from "axios"; import { getErrorMessage } from "coder/site/src/api/errors"; import * as module from "module"; import * as vscode from "vscode"; -import { makeCoderSdk, needToken } from "./api"; -import { errToStr } from "./api-helper"; +import { errToStr } from "./api/api-helper"; +import { needToken } from "./api/auth"; +import { CodeApi } from "./api/codeApi"; import { Commands } from "./commands"; import { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote"; @@ -61,21 +62,22 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // the plugin to poll workspaces for the current login, as well as being used // in commands that operate on the current login. const url = storage.getUrl(); - const restClient = makeCoderSdk( + const client = CodeApi.create( url || "", await storage.getSessionToken(), - storage, + storage.output, + () => vscode.workspace.getConfiguration(), ); const myWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.Mine, - restClient, + client, storage, 5, ); const allWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.All, - restClient, + client, storage, ); @@ -127,7 +129,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { storage.getUrl(), ); if (url) { - restClient.setHost(url); + client.setHost(url); await storage.setUrl(url); } else { throw new Error( @@ -141,11 +143,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // command currently always requires a token file. However, if there is // a query parameter for non-token auth go ahead and use it anyway; all // that really matters is the file is created. - const token = needToken() + const token = needToken(vscode.workspace.getConfiguration()) ? params.get("token") : (params.get("token") ?? ""); if (token) { - restClient.setSessionToken(token); + client.setSessionToken(token); await storage.setSessionToken(token); } @@ -209,7 +211,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { storage.getUrl(), ); if (url) { - restClient.setHost(url); + client.setHost(url); await storage.setUrl(url); } else { throw new Error( @@ -223,7 +225,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // command currently always requires a token file. However, if there is // a query parameter for non-token auth go ahead and use it anyway; all // that really matters is the file is created. - const token = needToken() + const token = needToken(vscode.workspace.getConfiguration()) ? params.get("token") : (params.get("token") ?? ""); @@ -248,7 +250,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. - const commands = new Commands(vscodeProposed, restClient, storage); + const commands = new Commands(vscodeProposed, client, storage); vscode.commands.registerCommand("coder.login", commands.login.bind(commands)); vscode.commands.registerCommand( "coder.logout", @@ -313,8 +315,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (details) { // Authenticate the plugin client which is used in the sidebar to display // workspaces belonging to this deployment. - restClient.setHost(details.url); - restClient.setSessionToken(details.token); + client.setHost(details.url); + client.setSessionToken(details.token); } } catch (ex) { if (ex instanceof CertificateError) { @@ -355,10 +357,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } // See if the plugin client is authenticated. - const baseUrl = restClient.getAxiosInstance().defaults.baseURL; + const baseUrl = client.getAxiosInstance().defaults.baseURL; if (baseUrl) { storage.output.info(`Logged in to ${baseUrl}; checking credentials`); - restClient + client .getAuthenticatedUser() .then(async (user) => { if (user && user.roles) { diff --git a/src/headers.test.ts b/src/headers.test.ts index 669a8d74..10e77f8d 100644 --- a/src/headers.test.ts +++ b/src/headers.test.ts @@ -2,7 +2,7 @@ import * as os from "os"; import { it, expect, describe, beforeEach, afterEach, vi } from "vitest"; import { WorkspaceConfiguration } from "vscode"; import { getHeaderCommand, getHeaders } from "./headers"; -import { Logger } from "./logger"; +import { Logger } from "./logging/logger"; const logger: Logger = { trace: () => {}, diff --git a/src/headers.ts b/src/headers.ts index e61bfa81..d259c9e1 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -2,7 +2,7 @@ import * as cp from "child_process"; import * as os from "os"; import * as util from "util"; import type { WorkspaceConfiguration } from "vscode"; -import { Logger } from "./logger"; +import { Logger } from "./logging/logger"; import { escapeCommandArg } from "./util"; interface ExecException { diff --git a/src/inbox.ts b/src/inbox.ts index 0ec79720..c93a74d5 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -1,14 +1,11 @@ -import { Api } from "coder/site/src/api/api"; import { Workspace, GetInboxNotificationResponse, } from "coder/site/src/api/typesGenerated"; -import { ProxyAgent } from "proxy-agent"; import * as vscode from "vscode"; -import { WebSocket } from "ws"; -import { coderSessionTokenHeader } from "./api"; -import { errToStr } from "./api-helper"; +import { CodeApi } from "./api/codeApi"; import { type Storage } from "./storage"; +import { OneWayWebSocket } from "./websocket/oneWayWebSocket"; // These are the template IDs of our notifications. // Maybe in the future we should avoid hardcoding @@ -19,67 +16,39 @@ const TEMPLATE_WORKSPACE_OUT_OF_DISK = "f047f6a3-5713-40f7-85aa-0394cce9fa3a"; export class Inbox implements vscode.Disposable { readonly #storage: Storage; #disposed = false; - #socket: WebSocket; + #socket: OneWayWebSocket; - constructor( - workspace: Workspace, - httpAgent: ProxyAgent, - restClient: Api, - storage: Storage, - ) { + constructor(workspace: Workspace, client: CodeApi, storage: Storage) { this.#storage = storage; - const baseUrlRaw = restClient.getAxiosInstance().defaults.baseURL; - if (!baseUrlRaw) { - throw new Error("No base URL set on REST client"); - } - const watchTemplates = [ TEMPLATE_WORKSPACE_OUT_OF_DISK, TEMPLATE_WORKSPACE_OUT_OF_MEMORY, ]; - const watchTemplatesParam = encodeURIComponent(watchTemplates.join(",")); const watchTargets = [workspace.id]; - const watchTargetsParam = encodeURIComponent(watchTargets.join(",")); - // We shouldn't need to worry about this throwing. Whilst `baseURL` could - // be an invalid URL, that would've caused issues before we got to here. - const baseUrl = new URL(baseUrlRaw); - const socketProto = baseUrl.protocol === "https:" ? "wss:" : "ws:"; - const socketUrl = `${socketProto}//${baseUrl.host}/api/v2/notifications/inbox/watch?format=plaintext&templates=${watchTemplatesParam}&targets=${watchTargetsParam}`; - - const token = restClient.getAxiosInstance().defaults.headers.common[ - coderSessionTokenHeader - ] as string | undefined; - this.#socket = new WebSocket(new URL(socketUrl), { - agent: httpAgent, - followRedirects: true, - headers: token - ? { - [coderSessionTokenHeader]: token, - } - : undefined, - }); + this.#socket = client.watchInboxNotifications(watchTemplates, watchTargets); - this.#socket.on("open", () => { + this.#socket.addEventListener("open", () => { this.#storage.output.info("Listening to Coder Inbox"); }); - this.#socket.on("error", (error) => { - this.notifyError(error); + this.#socket.addEventListener("error", () => { + // Errors are already logged internally this.dispose(); }); - this.#socket.on("message", (data) => { - try { - const inboxMessage = JSON.parse( - data.toString(), - ) as GetInboxNotificationResponse; - - vscode.window.showInformationMessage(inboxMessage.notification.title); - } catch (error) { - this.notifyError(error); + this.#socket.addEventListener("message", (data) => { + if (data.parseError) { + this.#storage.output.error( + "Failed to parse inbox message", + data.parseError, + ); + } else { + vscode.window.showInformationMessage( + data.parsedMessage.notification.title, + ); } }); } @@ -91,12 +60,4 @@ export class Inbox implements vscode.Disposable { this.#disposed = true; } } - - private notifyError(error: unknown) { - const message = errToStr( - error, - "Got empty error while monitoring Coder Inbox", - ); - this.#storage.output.error(message); - } } diff --git a/src/logging/formatters.ts b/src/logging/formatters.ts new file mode 100644 index 00000000..cb14bfe2 --- /dev/null +++ b/src/logging/formatters.ts @@ -0,0 +1,45 @@ +import type { InternalAxiosRequestConfig } from "axios"; +import prettyBytes from "pretty-bytes"; + +const SENSITIVE_HEADERS = ["Coder-Session-Token", "Proxy-Authorization"]; + +export function formatMethod(method: string | undefined): string { + return (method ?? "GET").toUpperCase(); +} + +export function formatContentLength(headers: Record): string { + const len = headers["content-length"]; + if (len && typeof len === "string") { + const bytes = parseInt(len, 10); + return isNaN(bytes) ? "(?b)" : `(${prettyBytes(bytes)})`; + } + return "(?b)"; +} + +export function formatUri( + config: InternalAxiosRequestConfig | undefined, +): string { + return config?.url || ""; +} + +export function formatHeaders(headers: Record): string { + const formattedHeaders = Object.entries(headers) + .map(([key, value]) => { + if (SENSITIVE_HEADERS.includes(key)) { + return `${key}: `; + } + return `${key}: ${value}`; + }) + .join("\n") + .trim(); + + return formattedHeaders.length > 0 ? formattedHeaders : ""; +} + +export function formatBody(body: unknown): string { + if (body) { + return JSON.stringify(body); + } else { + return ""; + } +} diff --git a/src/logging/httpLogger.ts b/src/logging/httpLogger.ts new file mode 100644 index 00000000..e4285e78 --- /dev/null +++ b/src/logging/httpLogger.ts @@ -0,0 +1,119 @@ +import type { + InternalAxiosRequestConfig, + AxiosResponse, + AxiosError, +} from "axios"; +import { isAxiosError } from "axios"; +import { getErrorMessage } from "coder/site/src/api/errors"; +import { getErrorDetail } from "../error"; +import { + formatHeaders, + formatBody, + formatUri, + formatContentLength, + formatMethod, +} from "./formatters"; +import type { Logger } from "./logger"; +import { + HttpClientLogLevel, + RequestMeta, + RequestConfigWithMeta, +} from "./types"; +import { shortId, formatTime, createRequestId } from "./utils"; + +export function createRequestMeta(): RequestMeta { + return { + requestId: createRequestId(), + startedAt: Date.now(), + }; +} + +export function logRequest( + logger: Logger, + requestId: string, + config: InternalAxiosRequestConfig, + logLevel: HttpClientLogLevel, +): void { + if (logLevel === HttpClientLogLevel.NONE) { + return; + } + + const method = formatMethod(config.method); + const url = formatUri(config); + const len = formatContentLength(config.headers); + + let msg = `→ ${shortId(requestId)} ${method} ${url} ${len}`; + if (logLevel >= HttpClientLogLevel.HEADERS) { + msg += `\n${formatHeaders(config.headers)}`; + } + + if (logLevel >= HttpClientLogLevel.BODY) { + msg += `\n${formatBody(config.data)}`; + } + + logger.trace(msg); +} + +export function logResponse( + logger: Logger, + meta: RequestMeta, + response: AxiosResponse, + logLevel: HttpClientLogLevel, +): void { + if (logLevel === HttpClientLogLevel.NONE) { + return; + } + + const method = formatMethod(response.config.method); + const url = formatUri(response.config); + const time = formatTime(Date.now() - meta.startedAt); + const len = formatContentLength(response.headers); + + let msg = `← ${shortId(meta.requestId)} ${response.status} ${method} ${url} ${len} ${time}`; + + if (logLevel >= HttpClientLogLevel.HEADERS) { + msg += `\n${formatHeaders(response.headers)}`; + } + + if (logLevel >= HttpClientLogLevel.BODY) { + msg += `\n${formatBody(response.data)}`; + } + + logger.trace(msg); +} + +export function logRequestError( + logger: Logger, + error: AxiosError | unknown, +): void { + if (isAxiosError(error)) { + const config = error.config as RequestConfigWithMeta | undefined; + const meta = config?.metadata; + const method = formatMethod(config?.method); + const url = formatUri(config); + const requestId = meta?.requestId || "unknown"; + const time = meta ? formatTime(Date.now() - meta.startedAt) : "?ms"; + + const msg = getErrorMessage(error, "No error message"); + const detail = getErrorDetail(error) ?? ""; + + if (error.response) { + const responseData = + error.response.statusText || String(error.response.data).slice(0, 100); + const errorInfo = [msg, detail, responseData].filter(Boolean).join(" - "); + logger.error( + `← ${shortId(requestId)} ${error.response.status} ${method} ${url} ${time} - ${errorInfo}`, + error, + ); + } else { + const reason = error.code || error.message || "Network error"; + const errorInfo = [msg, detail, reason].filter(Boolean).join(" - "); + logger.error( + `✗ ${shortId(requestId)} ${method} ${url} ${time} - ${errorInfo}`, + error, + ); + } + } else { + logger.error("Request error", error); + } +} diff --git a/src/logger.ts b/src/logging/logger.ts similarity index 100% rename from src/logger.ts rename to src/logging/logger.ts diff --git a/src/logging/types.ts b/src/logging/types.ts new file mode 100644 index 00000000..d1ee51ca --- /dev/null +++ b/src/logging/types.ts @@ -0,0 +1,17 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +export enum HttpClientLogLevel { + NONE, + BASIC, + HEADERS, + BODY, +} + +export interface RequestMeta { + requestId: string; + startedAt: number; +} + +export type RequestConfigWithMeta = InternalAxiosRequestConfig & { + metadata?: RequestMeta; +}; diff --git a/src/logging/utils.ts b/src/logging/utils.ts new file mode 100644 index 00000000..927c96d1 --- /dev/null +++ b/src/logging/utils.ts @@ -0,0 +1,46 @@ +import { Buffer } from "node:buffer"; +import crypto from "node:crypto"; + +export function shortId(id: string): string { + return id.slice(0, 8); +} + +export function formatTime(ms: number): string { + if (ms < 1000) { + return `${ms}ms`; + } + if (ms < 60000) { + return `${(ms / 1000).toFixed(2)}s`; + } + if (ms < 3600000) { + return `${(ms / 60000).toFixed(2)}m`; + } + return `${(ms / 3600000).toFixed(2)}h`; +} + +export function sizeOf(data: unknown): number | undefined { + if (data === null || data === undefined) { + return 0; + } + if (typeof data === "string") { + return Buffer.byteLength(data); + } + if (Buffer.isBuffer(data)) { + return data.length; + } + if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + return data.byteLength; + } + if ( + typeof data === "object" && + "size" in data && + typeof data.size === "number" + ) { + return data.size; + } + return undefined; +} + +export function createRequestId(): string { + return crypto.randomUUID().replace(/-/g, ""); +} diff --git a/src/logging/wsLogger.ts b/src/logging/wsLogger.ts new file mode 100644 index 00000000..0451d7b2 --- /dev/null +++ b/src/logging/wsLogger.ts @@ -0,0 +1,78 @@ +import prettyBytes from "pretty-bytes"; +import { errToStr } from "../api/api-helper"; +import type { Logger } from "./logger"; +import { shortId, formatTime, sizeOf, createRequestId } from "./utils"; + +const numFormatter = new Intl.NumberFormat("en", { + notation: "compact", + compactDisplay: "short", +}); + +export class WsLogger { + private readonly logger: Logger; + private readonly url: string; + private readonly id: string; + private readonly startedAt: number; + private openedAt?: number; + private msgCount = 0; + private byteCount = 0; + private unknownByteCount = false; + + constructor(logger: Logger, url: string) { + this.logger = logger; + this.url = url; + this.id = createRequestId(); + this.startedAt = Date.now(); + } + + logConnecting(): void { + this.logger.trace(`→ WS ${shortId(this.id)} ${this.url}`); + } + + logOpen(): void { + this.openedAt = Date.now(); + const time = formatTime(this.openedAt - this.startedAt); + this.logger.trace(`← WS ${shortId(this.id)} connected ${time}`); + } + + logMessage(data: unknown): void { + this.msgCount += 1; + const potentialSize = sizeOf(data); + if (potentialSize === undefined) { + this.unknownByteCount = true; + } else { + this.byteCount += potentialSize; + } + } + + logClose(code?: number, reason?: string): void { + const upMs = this.openedAt ? Date.now() - this.openedAt : 0; + const stats = [ + formatTime(upMs), + `${numFormatter.format(this.msgCount)} msgs`, + this.formatBytes(), + ]; + + const codeStr = code ? ` (${code})` : ""; + const reasonStr = reason ? ` - ${reason}` : ""; + const statsStr = ` [${stats.join(", ")}]`; + + this.logger.trace( + `▣ WS ${shortId(this.id)} closed${codeStr}${reasonStr}${statsStr}`, + ); + } + + logError(error: unknown, message: string): void { + const time = formatTime(Date.now() - this.startedAt); + const errorMsg = message || errToStr(error, "connection error"); + this.logger.error( + `✗ WS ${shortId(this.id)} error ${time} - ${errorMsg}`, + error, + ); + } + + private formatBytes(): string { + const bytes = prettyBytes(this.byteCount); + return this.unknownByteCount ? `>=${bytes}` : bytes; + } +} diff --git a/src/pgp.ts b/src/pgp.ts index 2b6043f2..c707c5b4 100644 --- a/src/pgp.ts +++ b/src/pgp.ts @@ -3,7 +3,7 @@ import * as openpgp from "openpgp"; import * as path from "path"; import { Readable } from "stream"; import * as vscode from "vscode"; -import { errToStr } from "./api-helper"; +import { errToStr } from "./api/api-helper"; export type Key = openpgp.Key; diff --git a/src/remote.ts b/src/remote.ts index b5165b4a..43b841a5 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -15,14 +15,10 @@ import { formatEventLabel, formatMetadataError, } from "./agentMetadataHelper"; -import { - createHttpAgent, - makeCoderSdk, - needToken, - startWorkspaceIfStoppedOrFailed, - waitForBuild, -} from "./api"; -import { extractAgents } from "./api-helper"; +import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; +import { needToken } from "./api/auth"; +import { CodeApi } from "./api/codeApi"; +import { startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api/workspace"; import * as cli from "./cliManager"; import { Commands } from "./commands"; import { featureSetForVersion, FeatureSet } from "./featureSet"; @@ -70,13 +66,13 @@ export class Remote { * Try to get the workspace running. Return undefined if the user canceled. */ private async maybeWaitForRunning( - restClient: Api, + client: CodeApi, workspace: Workspace, label: string, binPath: string, featureSet: FeatureSet, ): Promise { - const workspaceName = `${workspace.owner_name}/${workspace.name}`; + const workspaceName = createWorkspaceIdentifier(workspace); // A terminal will be used to stream the build, if one is necessary. let writeEmitter: undefined | vscode.EventEmitter; @@ -125,11 +121,7 @@ export class Remote { case "stopping": writeEmitter = initWriteEmitterAndTerminal(); this.storage.output.info(`Waiting for ${workspaceName}...`); - workspace = await waitForBuild( - restClient, - writeEmitter, - workspace, - ); + workspace = await waitForBuild(client, writeEmitter, workspace); break; case "stopped": if (!(await this.confirmStart(workspaceName))) { @@ -138,7 +130,7 @@ export class Remote { writeEmitter = initWriteEmitterAndTerminal(); this.storage.output.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( - restClient, + client, globalConfigDir, binPath, workspace, @@ -156,7 +148,7 @@ export class Remote { writeEmitter = initWriteEmitterAndTerminal(); this.storage.output.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( - restClient, + client, globalConfigDir, binPath, workspace, @@ -221,7 +213,10 @@ export class Remote { ); // It could be that the cli config was deleted. If so, ask for the url. - if (!baseUrlRaw || (!token && needToken())) { + if ( + !baseUrlRaw || + (!token && needToken(vscode.workspace.getConfiguration())) + ) { const result = await this.vscodeProposed.window.showInformationMessage( "You are not logged in...", { @@ -255,16 +250,18 @@ export class Remote { // break this connection. We could force close the remote session or // disallow logging out/in altogether, but for now just use a separate // client to remain unaffected by whatever the plugin is doing. - const workspaceRestClient = makeCoderSdk(baseUrlRaw, token, this.storage); + const workspaceClient = CodeApi.create( + baseUrlRaw, + token, + this.storage.output, + () => vscode.workspace.getConfiguration(), + ); // Store for use in commands. - this.commands.workspaceRestClient = workspaceRestClient; + this.commands.workspaceRestClient = workspaceClient; let binaryPath: string | undefined; if (this.mode === vscode.ExtensionMode.Production) { - binaryPath = await this.storage.fetchBinary( - workspaceRestClient, - parts.label, - ); + binaryPath = await this.storage.fetchBinary(workspaceClient, parts.label); } else { try { // In development, try to use `/tmp/coder` as the binary path. @@ -273,14 +270,14 @@ export class Remote { await fs.stat(binaryPath); } catch (ex) { binaryPath = await this.storage.fetchBinary( - workspaceRestClient, + workspaceClient, parts.label, ); } } // First thing is to check the version. - const buildInfo = await workspaceRestClient.getBuildInfo(); + const buildInfo = await workspaceClient.getBuildInfo(); let version: semver.SemVer | null = null; try { @@ -311,7 +308,7 @@ export class Remote { let workspace: Workspace; try { this.storage.output.info(`Looking for workspace ${workspaceName}...`); - workspace = await workspaceRestClient.getWorkspaceByOwnerAndName( + workspace = await workspaceClient.getWorkspaceByOwnerAndName( parts.username, parts.workspace, ); @@ -384,7 +381,7 @@ export class Remote { // If the workspace is not in a running state, try to get it running. if (workspace.latest_build.status !== "running") { const updatedWorkspace = await this.maybeWaitForRunning( - workspaceRestClient, + workspaceClient, workspace, parts.label, binaryPath, @@ -494,7 +491,7 @@ export class Remote { // Watch the workspace for changes. const monitor = new WorkspaceMonitor( workspace, - workspaceRestClient, + workspaceClient, this.storage, this.vscodeProposed, ); @@ -504,13 +501,7 @@ export class Remote { ); // Watch coder inbox for messages - const httpAgent = await createHttpAgent(); - const inbox = new Inbox( - workspace, - httpAgent, - workspaceRestClient, - this.storage, - ); + const inbox = new Inbox(workspace, workspaceClient, this.storage); disposables.push(inbox); // Wait for the agent to connect. @@ -579,7 +570,7 @@ export class Remote { try { this.storage.output.info("Updating SSH config..."); await this.updateSSHConfig( - workspaceRestClient, + workspaceClient, parts.label, parts.host, binaryPath, @@ -628,7 +619,7 @@ export class Remote { ); disposables.push( - ...this.createAgentMetadataStatusBar(agent, workspaceRestClient), + ...this.createAgentMetadataStatusBar(agent, workspaceClient), ); this.storage.output.info("Remote setup complete"); @@ -985,14 +976,14 @@ export class Remote { */ private createAgentMetadataStatusBar( agent: WorkspaceAgent, - restClient: Api, + client: CodeApi, ): vscode.Disposable[] { const statusBarItem = vscode.window.createStatusBarItem( "agentMetadata", vscode.StatusBarAlignment.Left, ); - const agentWatcher = createAgentMetadataWatcher(agent.id, restClient); + const agentWatcher = createAgentMetadataWatcher(agent.id, client); const onChangeDisposable = agentWatcher.onChange(() => { if (agentWatcher.error) { diff --git a/src/storage.ts b/src/storage.ts index 614b52aa..a5cf253f 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -10,7 +10,7 @@ import path from "path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; import * as vscode from "vscode"; -import { errToStr } from "./api-helper"; +import { errToStr } from "./api/api-helper"; import * as cli from "./cliManager"; import { getHeaderCommand, getHeaders } from "./headers"; import * as pgp from "./pgp"; diff --git a/src/websocket/oneWayWebSocket.ts b/src/websocket/oneWayWebSocket.ts new file mode 100644 index 00000000..d40c8d95 --- /dev/null +++ b/src/websocket/oneWayWebSocket.ts @@ -0,0 +1,142 @@ +/** + * A simplified wrapper over WebSockets using the 'ws' library that enforces + * one-way communication and supports automatic JSON parsing of messages. + * + * Similar to coder/site/src/utils/OneWayWebSocket.ts but uses `ws` library + * instead of the browser's WebSocket and also supports a custom base URL + * instead of always deriving it from `window.location`. + */ + +import { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; +import WebSocket, { type ClientOptions } from "ws"; + +export type OneWayMessageEvent = Readonly< + | { + sourceEvent: WebSocket.MessageEvent; + parsedMessage: TData; + parseError: undefined; + } + | { + sourceEvent: WebSocket.MessageEvent; + parsedMessage: undefined; + parseError: Error; + } +>; + +type OneWayEventPayloadMap = { + close: WebSocket.CloseEvent; + error: WebSocket.ErrorEvent; + message: OneWayMessageEvent; + open: WebSocket.Event; +}; + +type OneWayEventCallback = ( + payload: OneWayEventPayloadMap[TEvent], +) => void; + +interface OneWayWebSocketApi { + get url(): string; + addEventListener( + eventType: TEvent, + callback: OneWayEventCallback, + ): void; + removeEventListener( + eventType: TEvent, + callback: OneWayEventCallback, + ): void; + close(code?: number, reason?: string): void; +} + +type OneWayWebSocketInit = { + location: { protocol: string; host: string }; + apiRoute: string; + searchParams?: Record | URLSearchParams; + protocols?: string | string[]; + options?: ClientOptions; +}; + +export class OneWayWebSocket + implements OneWayWebSocketApi +{ + readonly #socket: WebSocket; + readonly #messageCallbacks = new Map< + OneWayEventCallback, + (data: WebSocket.RawData) => void + >(); + + constructor(init: OneWayWebSocketInit) { + const { location, apiRoute, protocols, options, searchParams } = init; + + const formattedParams = + searchParams instanceof URLSearchParams + ? searchParams + : new URLSearchParams(searchParams); + const paramsString = formattedParams.toString(); + const paramsSuffix = paramsString ? `?${paramsString}` : ""; + const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:"; + const url = `${wsProtocol}//${location.host}${apiRoute}${paramsSuffix}`; + + this.#socket = new WebSocket(url, protocols, options); + } + + get url(): string { + return this.#socket.url; + } + + addEventListener( + event: TEvent, + callback: OneWayEventCallback, + ): void { + if (event === "message") { + const messageCallback = callback as OneWayEventCallback; + + if (this.#messageCallbacks.has(messageCallback)) { + return; + } + + const wrapped = (data: WebSocket.RawData): void => { + try { + const message = JSON.parse(data.toString()) as TData; + messageCallback({ + sourceEvent: { data } as WebSocket.MessageEvent, + parseError: undefined, + parsedMessage: message, + }); + } catch (err) { + messageCallback({ + sourceEvent: { data } as WebSocket.MessageEvent, + parseError: err as Error, + parsedMessage: undefined, + }); + } + }; + + this.#socket.on("message", wrapped); + this.#messageCallbacks.set(messageCallback, wrapped); + } else { + // For other events, cast and add directly + this.#socket.on(event, callback); + } + } + + removeEventListener( + event: TEvent, + callback: OneWayEventCallback, + ): void { + if (event === "message") { + const messageCallback = callback as OneWayEventCallback; + const wrapper = this.#messageCallbacks.get(messageCallback); + + if (wrapper) { + this.#socket.off("message", wrapper); + this.#messageCallbacks.delete(messageCallback); + } + } else { + this.#socket.off(event, callback); + } + } + + close(code?: number, reason?: string): void { + this.#socket.close(code, reason); + } +} diff --git a/src/workspaceMonitor.ts b/src/workspaceMonitor.ts index d1eaf704..09420cbe 100644 --- a/src/workspaceMonitor.ts +++ b/src/workspaceMonitor.ts @@ -1,11 +1,10 @@ -import { Api } from "coder/site/src/api/api"; -import { Workspace } from "coder/site/src/api/typesGenerated"; +import { ServerSentEvent, Workspace } from "coder/site/src/api/typesGenerated"; import { formatDistanceToNowStrict } from "date-fns"; -import { EventSource } from "eventsource"; import * as vscode from "vscode"; -import { createStreamingFetchAdapter } from "./api"; -import { errToStr } from "./api-helper"; +import { createWorkspaceIdentifier, errToStr } from "./api/api-helper"; +import { CodeApi } from "./api/codeApi"; import { Storage } from "./storage"; +import { OneWayWebSocket } from "./websocket/oneWayWebSocket"; /** * Monitor a single workspace using SSE for events like shutdown and deletion. @@ -13,7 +12,7 @@ import { Storage } from "./storage"; * workspace status is also shown in the status bar menu. */ export class WorkspaceMonitor implements vscode.Disposable { - private eventSource: EventSource; + private socket: OneWayWebSocket; private disposed = false; // How soon in advance to notify about autostop and deletion. @@ -34,23 +33,26 @@ export class WorkspaceMonitor implements vscode.Disposable { constructor( workspace: Workspace, - private readonly restClient: Api, + private readonly client: CodeApi, private readonly storage: Storage, // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, ) { - this.name = `${workspace.owner_name}/${workspace.name}`; - const url = this.restClient.getAxiosInstance().defaults.baseURL; - const watchUrl = new URL(`${url}/api/v2/workspaces/${workspace.id}/watch`); - this.storage.output.info(`Monitoring ${this.name}...`); + this.name = createWorkspaceIdentifier(workspace); + const socket = this.client.watchWorkspace(workspace); - const eventSource = new EventSource(watchUrl.toString(), { - fetch: createStreamingFetchAdapter(this.restClient.getAxiosInstance()), + socket.addEventListener("open", () => { + this.storage.output.info(`Monitoring ${this.name}...`); }); - eventSource.addEventListener("data", (event) => { + socket.addEventListener("message", (event) => { try { - const newWorkspaceData = JSON.parse(event.data) as Workspace; + if (event.parseError) { + this.notifyError(event.parseError); + return; + } + // Perhaps we need to parse this and validate it. + const newWorkspaceData = event.parsedMessage.data as Workspace; this.update(newWorkspaceData); this.maybeNotify(newWorkspaceData); this.onChange.fire(newWorkspaceData); @@ -59,12 +61,8 @@ export class WorkspaceMonitor implements vscode.Disposable { } }); - eventSource.addEventListener("error", (event) => { - this.notifyError(event); - }); - // Store so we can close in dispose(). - this.eventSource = eventSource; + this.socket = socket; const statusBarItem = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Left, @@ -81,13 +79,13 @@ export class WorkspaceMonitor implements vscode.Disposable { } /** - * Permanently close the SSE stream. + * Permanently close the websocket. */ dispose() { if (!this.disposed) { this.storage.output.info(`Unmonitoring ${this.name}...`); this.statusBarItem.dispose(); - this.eventSource.close(); + this.socket.close(); this.disposed = true; } } @@ -181,10 +179,10 @@ export class WorkspaceMonitor implements vscode.Disposable { this.notifiedOutdated = true; - this.restClient + this.client .getTemplate(workspace.template_id) .then((template) => { - return this.restClient.getTemplateVersion(template.active_version_id); + return this.client.getTemplateVersion(template.active_version_id); }) .then((version) => { const infoMessage = version.message @@ -197,7 +195,7 @@ export class WorkspaceMonitor implements vscode.Disposable { vscode.commands.executeCommand( "coder.workspace.update", workspace, - this.restClient, + this.client, ); } }); diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index 278ee492..dcc0430a 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -1,4 +1,3 @@ -import { Api } from "coder/site/src/api/api"; import { Workspace, WorkspaceAgent, @@ -16,7 +15,8 @@ import { AgentMetadataEvent, extractAllAgents, extractAgents, -} from "./api-helper"; +} from "./api/api-helper"; +import { CodeApi } from "./api/codeApi"; import { Storage } from "./storage"; export enum WorkspaceQuery { @@ -45,7 +45,7 @@ export class WorkspaceProvider constructor( private readonly getWorkspacesQuery: WorkspaceQuery, - private readonly restClient: Api, + private readonly client: CodeApi, private readonly storage: Storage, private readonly timerSeconds?: number, ) { @@ -98,17 +98,18 @@ export class WorkspaceProvider } // If there is no URL configured, assume we are logged out. - const restClient = this.restClient; - const url = restClient.getAxiosInstance().defaults.baseURL; + const url = this.client.getAxiosInstance().defaults.baseURL; if (!url) { throw new Error("not logged in"); } - const resp = await restClient.getWorkspaces({ q: this.getWorkspacesQuery }); + const resp = await this.client.getWorkspaces({ + q: this.getWorkspacesQuery, + }); // We could have logged out while waiting for the query, or logged into a // different deployment. - const url2 = restClient.getAxiosInstance().defaults.baseURL; + const url2 = this.client.getAxiosInstance().defaults.baseURL; if (!url2) { throw new Error("not logged in"); } else if (url !== url2) { @@ -135,7 +136,7 @@ export class WorkspaceProvider return this.agentWatchers[agent.id]; } // Otherwise create a new watcher. - const watcher = createAgentMetadataWatcher(agent.id, restClient); + const watcher = createAgentMetadataWatcher(agent.id, this.client); watcher.onChange(() => this.refresh()); this.agentWatchers[agent.id] = watcher; return watcher;