From 57c9d887036e95da0d6203cd6aef09b1fb5bf7d3 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 20 Oct 2023 11:18:17 -0700 Subject: [PATCH] chore(site): remove terminal xservice (#10234) * Remove terminalXService This is a prelude to the change I actually want to make, which is to send the size of the terminal on the web socket URL after we do a fit. I have found xstate so confusing that it was easier to just rewrite it. * Fix hanging tests I am not really sure what ws.connected is doing but it seems to somehow block updates. Something to do with `act()` maybe? Basically, the useEffect creating the terminal never updates once the config query finishes, so the web socket is never created, and the test hangs forever. It might have been working before only because the web socket was created using xstate rather than useEffect and once it connected it would unblock and React could update again but this is just a guess. * Ignore other config changes The terminal only cares about the renderer specifically, no need to recreate the terminal if something else changes. * Break out port forward URL open to util Felt like this could be broken out to reduce the component size. Also trying to figure out why it is causing the terminal to create multiple times. * Prevent handleWebLink change from recreating terminal Depending on the timing, handleWebLink was causing the terminal to get recreated. We only need to create the terminal once unless the render type changes. Recreating the terminal was also recreating the web socket pointlessly. --- .../pages/TerminalPage/TerminalPage.test.tsx | 14 +- site/src/pages/TerminalPage/TerminalPage.tsx | 301 ++++++++------- site/src/utils/portForward.ts | 50 +++ site/src/utils/terminal.ts | 37 ++ site/src/utils/workspace.tsx | 17 + .../xServices/terminal/terminalXService.ts | 354 ------------------ 6 files changed, 264 insertions(+), 509 deletions(-) create mode 100644 site/src/utils/terminal.ts delete mode 100644 site/src/xServices/terminal/terminalXService.ts diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index e922f0ff4f..064a892d35 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -127,12 +127,19 @@ describe("TerminalPage", () => { const { container } = await renderTerminal(); // Then - await ws.connected; + // Ideally we could use ws.connected but that seems to pause React updates. + // For now, wait for the initial resize message instead. + await ws.nextMessage; ws.send(text); await expectTerminalText(container, text); ws.close(); }); + // Ideally we could just pass the correct size in the web socket URL without + // having to resize separately afterward (and then we could delete also this + // test), but we need the initial resize message to have something to wait for + // in the other tests since ws.connected appears to pause React updates. So + // for now the initial resize message (and this test) are here to stay. it("resizes on connect", async () => { // Given const ws = new WS( @@ -143,7 +150,6 @@ describe("TerminalPage", () => { await renderTerminal(); // Then - await ws.connected; const msg = await ws.nextMessage; const req = JSON.parse(new TextDecoder().decode(msg as Uint8Array)); expect(req.height).toBeGreaterThan(0); @@ -164,7 +170,9 @@ describe("TerminalPage", () => { ); // Then - await ws.connected; + // Ideally we could use ws.connected but that seems to pause React updates. + // For now, wait for the initial resize message instead. + await ws.nextMessage; ws.send(text); await expectTerminalText(container, text); ws.close(); diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 1761cd3a98..c3844fe051 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -1,5 +1,4 @@ import { makeStyles, useTheme } from "@mui/styles"; -import { useMachine } from "@xstate/react"; import { FC, useCallback, useEffect, useRef, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; @@ -14,14 +13,15 @@ import { Unicode11Addon } from "xterm-addon-unicode11"; import "xterm/css/xterm.css"; import { MONOSPACE_FONT_FAMILY } from "theme/constants"; import { pageTitle } from "utils/page"; -import { terminalMachine } from "xServices/terminal/terminalXService"; import { useProxy } from "contexts/ProxyContext"; import Box from "@mui/material/Box"; import { useDashboard } from "components/Dashboard/DashboardProvider"; import { Region } from "api/typesGenerated"; import { getLatencyColor } from "utils/latency"; import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency"; -import { portForwardURL } from "utils/portForward"; +import { openMaybePortForwardedURL } from "utils/portForward"; +import { terminalWebsocketUrl } from "utils/terminal"; +import { getMatchingAgentOrFirst } from "utils/workspace"; import { DisconnectedAlert, ErrorScriptAlert, @@ -30,6 +30,7 @@ import { } from "./TerminalAlerts"; import { useQuery } from "react-query"; import { deploymentConfig } from "api/queries/deployment"; +import { workspaceByOwnerAndName } from "api/queries/workspaces"; import { Popover, PopoverContent, @@ -48,9 +49,11 @@ const TerminalPage: FC = () => { const { proxy } = useProxy(); const params = useParams() as { username: string; workspace: string }; const username = params.username.replace("@", ""); - const workspaceName = params.workspace; const xtermRef = useRef(null); const [terminal, setTerminal] = useState(null); + const [terminalState, setTerminalState] = useState< + "connected" | "disconnected" | "initializing" + >("initializing"); const [fitAddon, setFitAddon] = useState(null); const [searchParams] = useSearchParams(); // The reconnection token is a unique token that identifies @@ -60,37 +63,13 @@ const TerminalPage: FC = () => { const command = searchParams.get("command") || undefined; // The workspace name is in the format: // [.] - const workspaceNameParts = workspaceName?.split("."); - const [terminalState, sendEvent] = useMachine(terminalMachine, { - context: { - agentName: workspaceNameParts?.[1], - reconnection: reconnectionToken, - workspaceName: workspaceNameParts?.[0], - username: username, - command: command, - baseURL: proxy.preferredPathAppURL, - }, - actions: { - readMessage: (_, event) => { - if (typeof event.data === "string") { - // This exclusively occurs when testing. - // "jest-websocket-mock" doesn't support ArrayBuffer. - terminal?.write(event.data); - } else { - terminal?.write(new Uint8Array(event.data)); - } - }, - }, - }); - const isConnected = terminalState.matches("connected"); - const isDisconnected = terminalState.matches("disconnected"); - const { - workspaceError, - workspace, - workspaceAgentError, - workspaceAgent, - websocketError, - } = terminalState.context; + const workspaceNameParts = params.workspace?.split("."); + const workspace = useQuery( + workspaceByOwnerAndName(username, workspaceNameParts?.[0]), + ); + const workspaceAgent = workspace.data + ? getMatchingAgentOrFirst(workspace.data, workspaceNameParts?.[1]) + : undefined; const dashboard = useDashboard(); const proxyContext = useProxy(); const selectedProxy = proxyContext.proxy.proxy; @@ -105,56 +84,25 @@ const TerminalPage: FC = () => { }, [lifecycleState]); const config = useQuery(deploymentConfig()); + const renderer = config.data?.config.web_terminal_renderer; // handleWebLink handles opening of URLs in the terminal! const handleWebLink = useCallback( (uri: string) => { - if ( - !workspaceAgent || - !workspace || - !username || - !proxy.preferredWildcardHostname - ) { - return; - } - - const open = (uri: string) => { - // Copied from: https://github.com/xtermjs/xterm.js/blob/master/addons/xterm-addon-web-links/src/WebLinksAddon.ts#L23 - const newWindow = window.open(); - if (newWindow) { - try { - newWindow.opener = null; - } catch { - // no-op, Electron can throw - } - newWindow.location.href = uri; - } else { - console.warn("Opening link blocked as opener could not be cleared"); - } - }; - - try { - const url = new URL(uri); - const localHosts = ["0.0.0.0", "127.0.0.1", "localhost"]; - if (!localHosts.includes(url.hostname)) { - open(uri); - return; - } - open( - portForwardURL( - proxy.preferredWildcardHostname, - parseInt(url.port), - workspaceAgent.name, - workspace.name, - username, - ) + url.pathname, - ); - } catch (ex) { - open(uri); - } + openMaybePortForwardedURL( + uri, + proxy.preferredWildcardHostname, + workspaceAgent?.name, + workspace.data?.name, + username, + ); }, - [workspaceAgent, workspace, username, proxy.preferredWildcardHostname], + [workspaceAgent, workspace.data, username, proxy.preferredWildcardHostname], ); + const handleWebLinkRef = useRef(handleWebLink); + useEffect(() => { + handleWebLinkRef.current = handleWebLink; + }, [handleWebLink]); // Create the terminal! useEffect(() => { @@ -171,9 +119,9 @@ const TerminalPage: FC = () => { background: colors.gray[16], }, }); - if (config.data?.config.web_terminal_renderer === "webgl") { + if (renderer === "webgl") { terminal.loadAddon(new WebglAddon()); - } else if (config.data?.config.web_terminal_renderer === "canvas") { + } else if (renderer === "canvas") { terminal.loadAddon(new CanvasAddon()); } const fitAddon = new FitAddon(); @@ -183,26 +131,9 @@ const TerminalPage: FC = () => { terminal.unicode.activeVersion = "11"; terminal.loadAddon( new WebLinksAddon((_, uri) => { - handleWebLink(uri); + handleWebLinkRef.current(uri); }), ); - terminal.onData((data) => { - sendEvent({ - type: "WRITE", - request: { - data: data, - }, - }); - }); - terminal.onResize((event) => { - sendEvent({ - type: "WRITE", - request: { - height: event.rows, - width: event.cols, - }, - }); - }); setTerminal(terminal); terminal.open(xtermRef.current); const listener = () => { @@ -214,11 +145,9 @@ const TerminalPage: FC = () => { window.removeEventListener("resize", listener); terminal.dispose(); }; - }, [config.data, config.isLoading, sendEvent, xtermRef, handleWebLink]); + }, [renderer, config.isLoading, xtermRef, handleWebLinkRef]); - // Triggers the initial terminal connection using - // the reconnection token and workspace name found - // from the router. + // Updates the reconnection token into the URL if necessary. useEffect(() => { if (searchParams.get("reconnect") === reconnectionToken) { return; @@ -234,7 +163,7 @@ const TerminalPage: FC = () => { ); }, [searchParams, navigate, reconnectionToken]); - // Apply terminal options based on connection state. + // Hook up the terminal through a web socket. useEffect(() => { if (!terminal || !fitAddon) { return; @@ -246,68 +175,136 @@ const TerminalPage: FC = () => { fitAddon.fit(); fitAddon.fit(); - if (!isConnected) { - // Disable user input when not connected. - terminal.options = { - disableStdin: true, - }; - if (workspaceError instanceof Error) { - terminal.writeln( - Language.workspaceErrorMessagePrefix + workspaceError.message, - ); - } - if (workspaceAgentError instanceof Error) { - terminal.writeln( - Language.workspaceAgentErrorMessagePrefix + - workspaceAgentError.message, - ); - } - if (websocketError instanceof Error) { - terminal.writeln( - Language.websocketErrorMessagePrefix + websocketError.message, - ); - } - return; - } - // The terminal should be cleared on each reconnect // because all data is re-rendered from the backend. terminal.clear(); - // Focusing on connection allows users to reload the - // page and start typing immediately. + // Focusing on connection allows users to reload the page and start + // typing immediately. terminal.focus(); - terminal.options = { - disableStdin: false, - windowsMode: workspaceAgent?.operating_system === "windows", - }; - // Update the terminal size post-fit. - sendEvent({ - type: "WRITE", - request: { - height: terminal.rows, - width: terminal.cols, - }, - }); + // Disable input while we connect. + terminal.options.disableStdin = true; + + // Show a message if we failed to find the workspace or agent. + if (workspace.isLoading) { + return; + } else if (workspace.error instanceof Error) { + terminal.writeln( + Language.workspaceErrorMessagePrefix + workspace.error.message, + ); + return; + } else if (!workspaceAgent) { + terminal.writeln( + Language.workspaceAgentErrorMessagePrefix + "no agent found with ID", + ); + return; + } + + // Hook up terminal events to the websocket. + let websocket: WebSocket | null; + const disposers = [ + terminal.onData((data) => { + websocket?.send( + new TextEncoder().encode(JSON.stringify({ data: data })), + ); + }), + terminal.onResize((event) => { + websocket?.send( + new TextEncoder().encode( + JSON.stringify({ + height: event.rows, + width: event.cols, + }), + ), + ); + }), + ]; + + let disposed = false; + + // Open the web socket and hook it up to the terminal. + terminalWebsocketUrl( + proxy.preferredPathAppURL, + reconnectionToken, + workspaceAgent.id, + command, + ) + .then((url) => { + if (disposed) { + return; // Unmounted while we waited for the async call. + } + websocket = new WebSocket(url); + websocket.binaryType = "arraybuffer"; + websocket.addEventListener("open", () => { + // Now that we are connected, allow user input. + terminal.options = { + disableStdin: false, + windowsMode: workspaceAgent?.operating_system === "windows", + }; + // Send the initial size. + websocket?.send( + new TextEncoder().encode( + JSON.stringify({ + height: terminal.rows, + width: terminal.cols, + }), + ), + ); + setTerminalState("connected"); + }); + websocket.addEventListener("error", () => { + terminal.options.disableStdin = true; + terminal.writeln( + Language.websocketErrorMessagePrefix + "socket errored", + ); + setTerminalState("disconnected"); + }); + websocket.addEventListener("close", () => { + terminal.options.disableStdin = true; + setTerminalState("disconnected"); + }); + websocket.addEventListener("message", (event) => { + if (typeof event.data === "string") { + // This exclusively occurs when testing. + // "jest-websocket-mock" doesn't support ArrayBuffer. + terminal.write(event.data); + } else { + terminal.write(new Uint8Array(event.data)); + } + }); + }) + .catch((error) => { + if (disposed) { + return; // Unmounted while we waited for the async call. + } + terminal.writeln(Language.websocketErrorMessagePrefix + error.message); + setTerminalState("disconnected"); + }); + + return () => { + disposed = true; // Could use AbortController instead? + disposers.forEach((d) => d.dispose()); + websocket?.close(1000); + }; }, [ - workspaceError, - workspaceAgentError, - websocketError, - workspaceAgent, - terminal, + command, fitAddon, - isConnected, - sendEvent, + proxy.preferredPathAppURL, + reconnectionToken, + terminal, + workspace.isLoading, + workspace.error, + workspaceAgent, ]); return ( <> - {terminalState.context.workspace + {workspace.data ? pageTitle( - `Terminal · ${terminalState.context.workspace.owner_name}/${terminalState.context.workspace.name}`, + `Terminal · ${workspace.data.owner_name}/${workspace.data.name}`, ) : ""} @@ -317,7 +314,7 @@ const TerminalPage: FC = () => { {lifecycleState === "starting" && } {lifecycleState === "ready" && prevLifecycleState.current === "starting" && } - {isDisconnected && } + {terminalState === "disconnected" && }
{ + const open = (uri: string) => { + // Copied from: https://github.com/xtermjs/xterm.js/blob/master/addons/xterm-addon-web-links/src/WebLinksAddon.ts#L23 + const newWindow = window.open(); + if (newWindow) { + try { + newWindow.opener = null; + } catch { + // no-op, Electron can throw + } + newWindow.location.href = uri; + } else { + console.warn("Opening link blocked as opener could not be cleared"); + } + }; + + if (!agentName || !workspaceName || !username || !proxyHost) { + open(uri); + return; + } + + try { + const url = new URL(uri); + const localHosts = ["0.0.0.0", "127.0.0.1", "localhost"]; + if (!localHosts.includes(url.hostname)) { + open(uri); + return; + } + open( + portForwardURL( + proxyHost, + parseInt(url.port), + agentName, + workspaceName, + username, + ) + url.pathname, + ); + } catch (ex) { + open(uri); + } +}; diff --git a/site/src/utils/terminal.ts b/site/src/utils/terminal.ts new file mode 100644 index 0000000000..52d46feaaf --- /dev/null +++ b/site/src/utils/terminal.ts @@ -0,0 +1,37 @@ +import * as API from "api/api"; + +export const terminalWebsocketUrl = async ( + baseUrl: string | undefined, + reconnect: string, + agentId: string, + command: string | undefined, +): Promise => { + const query = new URLSearchParams({ reconnect }); + if (command) { + query.set("command", command); + } + + const url = new URL(baseUrl || `${location.protocol}//${location.host}`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + if (!url.pathname.endsWith("/")) { + url.pathname + "/"; + } + url.pathname += `api/v2/workspaceagents/${agentId}/pty`; + url.search = "?" + query.toString(); + + // If the URL is just the primary API, we don't need a signed token to + // connect. + if (!baseUrl) { + return url.toString(); + } + + // Do ticket issuance and set the query parameter. + const tokenRes = await API.issueReconnectingPTYSignedToken({ + url: url.toString(), + agentID: agentId, + }); + query.set("coder_signed_app_token_23db1dde", tokenRes.signed_token); + url.search = "?" + query.toString(); + + return url.toString(); +}; diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx index 8e4c6596e4..9365e5d615 100644 --- a/site/src/utils/workspace.tsx +++ b/site/src/utils/workspace.tsx @@ -286,3 +286,20 @@ export const hasJobError = (workspace: TypesGen.Workspace) => { export const paramsUsedToCreateWorkspace = ( param: TypesGen.TemplateVersionParameter, ) => !param.ephemeral; + +export const getMatchingAgentOrFirst = ( + workspace: TypesGen.Workspace, + agentName: string | undefined, +): TypesGen.WorkspaceAgent | undefined => { + return workspace.latest_build.resources + .map((resource) => { + if (!resource.agents || resource.agents.length === 0) { + return; + } + if (!agentName) { + return resource.agents[0]; + } + return resource.agents.find((agent) => agent.name === agentName); + }) + .filter((a) => a)[0]; +}; diff --git a/site/src/xServices/terminal/terminalXService.ts b/site/src/xServices/terminal/terminalXService.ts deleted file mode 100644 index b983726ea2..0000000000 --- a/site/src/xServices/terminal/terminalXService.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { assign, createMachine } from "xstate"; -import * as API from "api/api"; -import * as TypesGen from "api/typesGenerated"; - -interface ReconnectingPTYRequest { - readonly data?: string; - readonly height?: number; - readonly width?: number; -} - -export interface TerminalContext { - workspaceError?: unknown; - workspace?: TypesGen.Workspace; - workspaceAgent?: TypesGen.WorkspaceAgent; - workspaceAgentError?: unknown; - websocket?: WebSocket; - websocketError?: unknown; - websocketURL?: string; - websocketURLError?: unknown; - - // Assigned by connecting! - // The workspace agent is entirely optional. If the agent is omitted the - // first agent will be used. - agentName?: string; - username?: string; - workspaceName?: string; - reconnection?: string; - command?: string; - // If baseURL is not..... - baseURL?: string; -} - -export type TerminalEvent = - | { - type: "CONNECT"; - agentName?: string; - reconnection?: string; - workspaceName?: string; - username?: string; - } - | { type: "WRITE"; request: ReconnectingPTYRequest } - | { type: "READ"; data: ArrayBuffer } - | { type: "DISCONNECT" }; - -export const terminalMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOljGQFcAHAYggHscxLSLVNdCSz2VWnQDaABgC6iUHWawsyLK2kgAHogAcAVgCM5MQGYdAJgMaALOYDsV8zq0AaEAE9ExgGwHyATmPf3VmI6Yub+5gYAvhFO3Nj4xJyC1PTkMMgA6sxoANawdHgAxuxpijhQmTl5hWBMrOy4AG7M2cXUFbn5ReJSSCCy8orKveoI5qbkOhq23hozflruxuZOrghGXuZaRsFiWlbeWuYa7lEx6HF8iZTJdKltWR3Vd8il5Q9VRQzoaFnkdARkABmWQwz3aHzA3RU-QUShwKhGYy8k2msw080WyxcbmMYnIoW8hO8ZkMYisOlOIFivASAmer3BnTAAEEYDhkLU2ORGs1Whl3kzWWB2VDejDBvDhogdAYrFoJuYxJjdmJvCENCs3Fo8e4glpjFptWSDhpKdT4vwKCVcG9KoK2Rzvr9-kCQWCBdUhSLJNC5LChqARotNQgpniNAYtAdjFYDL4pidolTzjTLXyGWAAEZEZgFFrIACqACUADKc+o4JotZ45vPUYsl0UyP0ShGIWVWchGA27Akzbwh4LjDQmAk6AmBbxmlMWq7WsrpLO1-MNr5oH5oP4A5DAzA13Mr0tNvotuFthBWYyDo56ULGewWHTk0zGac8Wd0gqsNgFV7l7mVry5BfjgP7IMe4pnlKobmO45D3mG7ihFMBgBIOhgaPoizar4xIRv4b4XLSFAgWBNprhuW6unupFgL+EGngGaiIMG2IIPYtj4psMYaGIxh+Do9iEamVy0b+kAMOkRYAJIACoAKIMQMUGBogdiYZs3Z+N444GqYg42F4kY6LqmxWLMUaREm5qXJ+350agEAMEW8nMgAIkp-qSqpow6N45BIRYkYRgsBxyoOASdnG2gyu43jcUhwkfiR9niU5bnSUQADCADyAByeXyVlsmea20F+Zho6EksgU6WIGpsZMuj6OZfimLB0bmEltkUBAWCwGJjkMLlBVFSVPpiox3nMexV6Na+lI4MwEBwCoNnEWAvrKUxIwALRjCGu1yuQRpiCEBpyrshLdRt1zCFtXnnu4IabCdZ1nWMezalGFLWTOPVJMI7p2tUD1lT5hyDgYXjvR9KrxSZXV-e+AN3SkaSMk8862o8RRgypM1DiGL4+FY7iGvqniXvYSNnCjt1COj9wg0UlA0AURSwPAk3bdNQbQ-o3YLCYHGwcTRwBeE-GYjToRWDdab0jamNFF6yD4ztiD+PKgTdtYljuAEBjE3G+J+HFI7ah45IK3O1AZtmB71qWGt84gezofe+j2Lq7h+XxSFWXTRGK4NNqu09fFYUssyLPxCyOI1hjyh4CzW1b3jy8jIeialjkR9BhzweTiwBPqOm2Asg5TOYXbqmY6xISEtt0n1A155ABc+aheiGCYfhGAnthWIO33kOSxwRn3vgBFEURAA */ - createMachine( - { - id: "terminalState", - predictableActionArguments: true, - tsTypes: {} as import("./terminalXService.typegen").Typegen0, - schema: { - context: {} as TerminalContext, - events: {} as TerminalEvent, - services: {} as { - getWorkspace: { - data: TypesGen.Workspace; - }; - getWorkspaceAgent: { - data: TypesGen.WorkspaceAgent; - }; - getWebsocketURL: { - data: string; - }; - connect: { - data: WebSocket; - }; - }, - }, - initial: "setup", - states: { - setup: { - type: "parallel", - states: { - getWorkspace: { - initial: "gettingWorkspace", - states: { - gettingWorkspace: { - invoke: { - src: "getWorkspace", - id: "getWorkspace", - onDone: [ - { - actions: ["assignWorkspace", "clearWorkspaceError"], - target: "success", - }, - ], - onError: [ - { - actions: "assignWorkspaceError", - target: "success", - }, - ], - }, - }, - success: { - type: "final", - }, - }, - }, - }, - onDone: { - target: "gettingWorkspaceAgent", - }, - }, - gettingWorkspaceAgent: { - invoke: { - src: "getWorkspaceAgent", - id: "getWorkspaceAgent", - onDone: [ - { - actions: ["assignWorkspaceAgent", "clearWorkspaceAgentError"], - target: "gettingWebSocketURL", - }, - ], - onError: [ - { - actions: "assignWorkspaceAgentError", - target: "disconnected", - }, - ], - }, - }, - gettingWebSocketURL: { - invoke: { - src: "getWebsocketURL", - id: "getWebsocketURL", - onDone: [ - { - actions: ["assignWebsocketURL", "clearWebsocketURLError"], - target: "connecting", - }, - ], - onError: [ - { - actions: "assignWebsocketURLError", - target: "disconnected", - }, - ], - }, - }, - connecting: { - invoke: { - src: "connect", - id: "connect", - onDone: [ - { - actions: ["assignWebsocket", "clearWebsocketError"], - target: "connected", - }, - ], - onError: [ - { - actions: "assignWebsocketError", - target: "disconnected", - }, - ], - }, - }, - connected: { - on: { - WRITE: { - actions: "sendMessage", - }, - READ: { - actions: "readMessage", - }, - DISCONNECT: { - actions: "disconnect", - target: "disconnected", - }, - }, - }, - disconnected: { - on: { - CONNECT: { - actions: "assignConnection", - target: "gettingWorkspaceAgent", - }, - }, - }, - }, - }, - { - services: { - getWorkspace: async (context) => { - if (!context.workspaceName) { - throw new Error("workspace name not set"); - } - return API.getWorkspaceByOwnerAndName( - context.username, - context.workspaceName, - ); - }, - getWorkspaceAgent: async (context) => { - if (!context.workspace || !context.workspaceName) { - throw new Error("workspace or workspace name is not set"); - } - - const agent = context.workspace.latest_build.resources - .map((resource) => { - if (!resource.agents || resource.agents.length === 0) { - return; - } - if (!context.agentName) { - return resource.agents[0]; - } - return resource.agents.find( - (agent) => agent.name === context.agentName, - ); - }) - .filter((a) => a)[0]; - if (!agent) { - throw new Error("no agent found with id"); - } - return agent; - }, - getWebsocketURL: async (context) => { - if (!context.workspaceAgent) { - throw new Error("workspace agent is not set"); - } - if (!context.reconnection) { - throw new Error("reconnection ID is not set"); - } - - let baseURL = context.baseURL || ""; - if (!baseURL) { - baseURL = `${location.protocol}//${location.host}`; - } - - const query = new URLSearchParams({ - reconnect: context.reconnection, - }); - if (context.command) { - query.set("command", context.command); - } - - const url = new URL(baseURL); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - if (!url.pathname.endsWith("/")) { - url.pathname + "/"; - } - url.pathname += `api/v2/workspaceagents/${context.workspaceAgent.id}/pty`; - url.search = "?" + query.toString(); - - // If the URL is just the primary API, we don't need a signed token to - // connect. - if (!context.baseURL) { - return url.toString(); - } - - // Do ticket issuance and set the query parameter. - const tokenRes = await API.issueReconnectingPTYSignedToken({ - url: url.toString(), - agentID: context.workspaceAgent.id, - }); - query.set("coder_signed_app_token_23db1dde", tokenRes.signed_token); - url.search = "?" + query.toString(); - - return url.toString(); - }, - connect: (context) => (send) => { - return new Promise((resolve, reject) => { - if (!context.workspaceAgent) { - return reject("workspace agent is not set"); - } - if (!context.websocketURL) { - return reject("websocket URL is not set"); - } - - const socket = new WebSocket(context.websocketURL); - socket.binaryType = "arraybuffer"; - socket.addEventListener("open", () => { - resolve(socket); - }); - socket.addEventListener("error", () => { - reject(new Error("socket errored")); - }); - socket.addEventListener("close", () => { - send({ - type: "DISCONNECT", - }); - }); - socket.addEventListener("message", (event) => { - send({ - type: "READ", - data: event.data, - }); - }); - }); - }, - }, - actions: { - assignConnection: assign((context, event) => ({ - ...context, - agentName: event.agentName ?? context.agentName, - reconnection: event.reconnection ?? context.reconnection, - workspaceName: event.workspaceName ?? context.workspaceName, - })), - assignWorkspace: assign({ - workspace: (_, event) => event.data, - }), - assignWorkspaceError: assign({ - workspaceError: (_, event) => event.data, - }), - clearWorkspaceError: assign((context) => ({ - ...context, - workspaceError: undefined, - })), - assignWorkspaceAgent: assign({ - workspaceAgent: (_, event) => event.data, - }), - assignWorkspaceAgentError: assign({ - workspaceAgentError: (_, event) => event.data, - }), - clearWorkspaceAgentError: assign((context: TerminalContext) => ({ - ...context, - workspaceAgentError: undefined, - })), - assignWebsocket: assign({ - websocket: (_, event) => event.data, - }), - assignWebsocketError: assign({ - websocketError: (_, event) => event.data, - }), - clearWebsocketError: assign((context: TerminalContext) => ({ - ...context, - webSocketError: undefined, - })), - assignWebsocketURL: assign({ - websocketURL: (context, event) => event.data ?? context.websocketURL, - }), - assignWebsocketURLError: assign({ - websocketURLError: (_, event) => event.data, - }), - clearWebsocketURLError: assign((context: TerminalContext) => ({ - ...context, - websocketURLError: undefined, - })), - sendMessage: (context, event) => { - if (!context.websocket) { - throw new Error("websocket doesn't exist"); - } - context.websocket.send( - new TextEncoder().encode(JSON.stringify(event.request)), - ); - }, - disconnect: (context: TerminalContext) => { - // Code 1000 is a successful exit! - context.websocket?.close(1000); - }, - }, - }, - );