diff --git a/src/api.ts b/src/api.ts index 51e15416..f6f59ba8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -28,7 +28,7 @@ export function needToken(): boolean { /** * Create a new agent based off the current settings. */ -async function createHttpAgent(): Promise { +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()) diff --git a/src/inbox.ts b/src/inbox.ts new file mode 100644 index 00000000..34a87a5e --- /dev/null +++ b/src/inbox.ts @@ -0,0 +1,83 @@ +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 { errToStr } from "./api-helper" +import { type Storage } from "./storage" + +// These are the template IDs of our notifications. +// Maybe in the future we should avoid hardcoding +// these in both coderd and here. +const TEMPLATE_WORKSPACE_OUT_OF_MEMORY = "a9d027b4-ac49-4fb1-9f6d-45af15f64e7a" +const TEMPLATE_WORKSPACE_OUT_OF_DISK = "f047f6a3-5713-40f7-85aa-0394cce9fa3a" + +export class Inbox implements vscode.Disposable { + readonly #storage: Storage + #disposed = false + #socket: WebSocket + + constructor(workspace: Workspace, httpAgent: ProxyAgent, restClient: Api, 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 coderSessionTokenHeader = "Coder-Session-Token" + this.#socket = new WebSocket(new URL(socketUrl), { + followRedirects: true, + agent: httpAgent, + headers: { + [coderSessionTokenHeader]: restClient.getAxiosInstance().defaults.headers.common[coderSessionTokenHeader] as + | string + | undefined, + }, + }) + + this.#socket.on("open", () => { + this.#storage.writeToCoderOutputChannel("Listening to Coder Inbox") + }) + + this.#socket.on("error", (error) => { + this.notifyError(error) + 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) + } + }) + } + + dispose() { + if (!this.#disposed) { + this.#storage.writeToCoderOutputChannel("No longer listening to Coder Inbox") + this.#socket.close() + this.#disposed = true + } + } + + private notifyError(error: unknown) { + const message = errToStr(error, "Got empty error while monitoring Coder Inbox") + this.#storage.writeToCoderOutputChannel(message) + } +} diff --git a/src/remote.ts b/src/remote.ts index 54bb64be..f5ec9f63 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -9,12 +9,13 @@ import * as path from "path" import prettyBytes from "pretty-bytes" import * as semver from "semver" import * as vscode from "vscode" -import { makeCoderSdk, needToken, startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api" +import { createHttpAgent, makeCoderSdk, needToken, startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api" import { extractAgents } from "./api-helper" import * as cli from "./cliManager" import { Commands } from "./commands" import { featureSetForVersion, FeatureSet } from "./featureSet" import { getHeaderCommand } from "./headers" +import { Inbox } from "./inbox" import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig" import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport" import { Storage } from "./storage" @@ -403,6 +404,11 @@ export class Remote { disposables.push(monitor) disposables.push(monitor.onChange.event((w) => (this.commands.workspace = w))) + // Watch coder inbox for messages + const httpAgent = await createHttpAgent() + const inbox = new Inbox(workspace, httpAgent, workspaceRestClient, this.storage) + disposables.push(inbox) + // Wait for the agent to connect. if (agent.status === "connecting") { this.storage.writeToCoderOutputChannel(`Waiting for ${workspaceName}/${agent.name}...`) diff --git a/yarn.lock b/yarn.lock index 907f0855..9bc237f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1594,7 +1594,7 @@ co@3.1.0: "coder@https://github.com/coder/coder#main": version "0.0.0" - resolved "https://github.com/coder/coder#975ea23d6f49a4043131f79036d1bf5166eb9140" + resolved "https://github.com/coder/coder#3ac844ad3d341d2910542b83d4f33df7bd0be85e" collapse-white-space@^1.0.2: version "1.0.6"