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.
This commit is contained in:
Asher
2023-10-20 10:18:17 -08:00
committed by GitHub
parent 5ebb702e00
commit 57c9d88703
6 changed files with 264 additions and 509 deletions
@@ -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();
+149 -152
View File
@@ -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<HTMLDivElement>(null);
const [terminal, setTerminal] = useState<XTerm.Terminal | null>(null);
const [terminalState, setTerminalState] = useState<
"connected" | "disconnected" | "initializing"
>("initializing");
const [fitAddon, setFitAddon] = useState<FitAddon | null>(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:
// <workspace name>[.<agent name>]
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 (
<>
<Helmet>
<title>
{terminalState.context.workspace
{workspace.data
? pageTitle(
`Terminal · ${terminalState.context.workspace.owner_name}/${terminalState.context.workspace.name}`,
`Terminal · ${workspace.data.owner_name}/${workspace.data.name}`,
)
: ""}
</title>
@@ -317,7 +314,7 @@ const TerminalPage: FC = () => {
{lifecycleState === "starting" && <LoadingScriptsAlert />}
{lifecycleState === "ready" &&
prevLifecycleState.current === "starting" && <LoadedScriptsAlert />}
{isDisconnected && <DisconnectedAlert />}
{terminalState === "disconnected" && <DisconnectedAlert />}
<div
className={styles.terminal}
ref={xtermRef}
+50
View File
@@ -12,3 +12,53 @@ export const portForwardURL = (
}--${agentName}--${workspaceName}--${username}`;
return `${location.protocol}//${host}`.replace("*", subdomain);
};
// openMaybePortForwardedURL tries to open the provided URI through the
// port-forwarded URL if it is localhost, otherwise opens it normally.
export const openMaybePortForwardedURL = (
uri: string,
proxyHost?: string,
agentName?: string,
workspaceName?: string,
username?: string,
) => {
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);
}
};
+37
View File
@@ -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<string> => {
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();
};
+17
View File
@@ -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];
};
@@ -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<WebSocket>((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);
},
},
},
);