From 870a9b6e6d488ff1e95d7e8e470379c9f10d29b8 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 8 Jul 2025 14:23:39 +0000 Subject: [PATCH 1/8] feat: auto reconnect the terminal --- site/package.json | 7 +- site/pnpm-lock.yaml | 15 + site/src/hooks/useWithRetry.test.ts | 329 ------------------ site/src/hooks/useWithRetry.ts | 106 ------ .../src/pages/TerminalPage/TerminalAlerts.tsx | 6 +- site/src/pages/TerminalPage/TerminalPage.tsx | 3 + 6 files changed, 28 insertions(+), 438 deletions(-) delete mode 100644 site/src/hooks/useWithRetry.test.ts delete mode 100644 site/src/hooks/useWithRetry.ts diff --git a/site/package.json b/site/package.json index 1512a803b0a96..def934fa55dd1 100644 --- a/site/package.json +++ b/site/package.json @@ -93,6 +93,7 @@ "lodash": "4.17.21", "lucide-react": "0.474.0", "monaco-editor": "0.52.0", + "partysocket": "1.1.4", "pretty-bytes": "6.1.1", "react": "18.3.1", "react-color": "2.19.3", @@ -190,7 +191,11 @@ "vite-plugin-checker": "0.9.3", "vite-plugin-turbosnap": "1.0.3" }, - "browserslist": ["chrome 110", "firefox 111", "safari 16.0"], + "browserslist": [ + "chrome 110", + "firefox 111", + "safari 16.0" + ], "resolutions": { "optionator": "0.9.3", "semver": "7.6.2" diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 62cdc6176092a..d7f72dd60c5b1 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: monaco-editor: specifier: 0.52.0 version: 0.52.0 + partysocket: + specifier: 1.1.4 + version: 1.1.4 pretty-bytes: specifier: 6.1.1 version: 6.1.1 @@ -3729,6 +3732,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, tarball: https://registry.npmjs.org/etag/-/etag-1.8.1.tgz} engines: {node: '>= 0.6'} + event-target-polyfill@0.0.4: + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==, tarball: https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz} @@ -5106,6 +5112,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, tarball: https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz} engines: {node: '>= 0.8'} + partysocket@1.1.4: + resolution: {integrity: sha512-jXP7PFj2h5/v4UjDS8P7MZy6NJUQ7sspiFyxL4uc/+oKOL+KdtXzHnTV8INPGxBrLTXgalyG3kd12Qm7WrYc3A==, tarball: https://registry.npmjs.org/partysocket/-/partysocket-1.1.4.tgz} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} engines: {node: '>=8'} @@ -10004,6 +10013,8 @@ snapshots: etag@1.8.1: {} + event-target-polyfill@0.0.4: {} + eventemitter3@4.0.7: {} execa@5.1.1: @@ -11968,6 +11979,10 @@ snapshots: parseurl@1.3.3: {} + partysocket@1.1.4: + dependencies: + event-target-polyfill: 0.0.4 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} diff --git a/site/src/hooks/useWithRetry.test.ts b/site/src/hooks/useWithRetry.test.ts deleted file mode 100644 index 7ed7b4331f21e..0000000000000 --- a/site/src/hooks/useWithRetry.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { useWithRetry } from "./useWithRetry"; - -// Mock timers -jest.useFakeTimers(); - -describe("useWithRetry", () => { - let mockFn: jest.Mock; - - beforeEach(() => { - mockFn = jest.fn(); - jest.clearAllTimers(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it("should initialize with correct default state", () => { - const { result } = renderHook(() => useWithRetry(mockFn)); - - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).toBe(undefined); - }); - - it("should execute function successfully on first attempt", async () => { - mockFn.mockResolvedValue(undefined); - - const { result } = renderHook(() => useWithRetry(mockFn)); - - await act(async () => { - await result.current.call(); - }); - - expect(mockFn).toHaveBeenCalledTimes(1); - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).toBe(undefined); - }); - - it("should set isLoading to true during execution", async () => { - let resolvePromise: () => void; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockFn.mockReturnValue(promise); - - const { result } = renderHook(() => useWithRetry(mockFn)); - - act(() => { - result.current.call(); - }); - - expect(result.current.isLoading).toBe(true); - - await act(async () => { - resolvePromise!(); - await promise; - }); - - expect(result.current.isLoading).toBe(false); - }); - - it("should retry on failure with exponential backoff", async () => { - mockFn - .mockRejectedValueOnce(new Error("First failure")) - .mockRejectedValueOnce(new Error("Second failure")) - .mockResolvedValueOnce(undefined); - - const { result } = renderHook(() => useWithRetry(mockFn)); - - // Start the call - await act(async () => { - await result.current.call(); - }); - - expect(mockFn).toHaveBeenCalledTimes(1); - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).not.toBe(null); - - // Fast-forward to first retry (1 second) - await act(async () => { - jest.advanceTimersByTime(1000); - }); - - expect(mockFn).toHaveBeenCalledTimes(2); - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).not.toBe(null); - - // Fast-forward to second retry (2 seconds) - await act(async () => { - jest.advanceTimersByTime(2000); - }); - - expect(mockFn).toHaveBeenCalledTimes(3); - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).toBe(undefined); - }); - - it("should continue retrying without limit", async () => { - mockFn.mockRejectedValue(new Error("Always fails")); - - const { result } = renderHook(() => useWithRetry(mockFn)); - - // Start the call - await act(async () => { - await result.current.call(); - }); - - expect(mockFn).toHaveBeenCalledTimes(1); - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).not.toBe(null); - - // Fast-forward through multiple retries to verify it continues - for (let i = 1; i < 15; i++) { - const delay = Math.min(1000 * 2 ** (i - 1), 600000); // exponential backoff with max delay - await act(async () => { - jest.advanceTimersByTime(delay); - }); - expect(mockFn).toHaveBeenCalledTimes(i + 1); - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).not.toBe(null); - } - - // Should still be retrying after 15 attempts - expect(result.current.nextRetryAt).not.toBe(null); - }); - - it("should respect max delay of 10 minutes", async () => { - mockFn.mockRejectedValue(new Error("Always fails")); - - const { result } = renderHook(() => useWithRetry(mockFn)); - - // Start the call - await act(async () => { - await result.current.call(); - }); - - expect(result.current.isLoading).toBe(false); - - // Fast-forward through several retries to reach max delay - // After attempt 9, delay would be 1000 * 2^9 = 512000ms, which is less than 600000ms (10 min) - // After attempt 10, delay would be 1000 * 2^10 = 1024000ms, which should be capped at 600000ms - - // Skip to attempt 9 (delay calculation: 1000 * 2^8 = 256000ms) - for (let i = 1; i < 9; i++) { - const delay = 1000 * 2 ** (i - 1); - await act(async () => { - jest.advanceTimersByTime(delay); - }); - } - - expect(mockFn).toHaveBeenCalledTimes(9); - expect(result.current.nextRetryAt).not.toBe(null); - - // The 9th retry should use max delay (600000ms = 10 minutes) - await act(async () => { - jest.advanceTimersByTime(600000); - }); - - expect(mockFn).toHaveBeenCalledTimes(10); - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).not.toBe(null); - - // Continue with more retries at max delay to verify it continues indefinitely - await act(async () => { - jest.advanceTimersByTime(600000); - }); - - expect(mockFn).toHaveBeenCalledTimes(11); - expect(result.current.nextRetryAt).not.toBe(null); - }); - - it("should cancel previous retry when call is invoked again", async () => { - mockFn - .mockRejectedValueOnce(new Error("First failure")) - .mockResolvedValueOnce(undefined); - - const { result } = renderHook(() => useWithRetry(mockFn)); - - // Start the first call - await act(async () => { - await result.current.call(); - }); - - expect(mockFn).toHaveBeenCalledTimes(1); - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).not.toBe(null); - - // Call again before retry happens - await act(async () => { - await result.current.call(); - }); - - expect(mockFn).toHaveBeenCalledTimes(2); - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).toBe(undefined); - - // Advance time to ensure previous retry was cancelled - await act(async () => { - jest.advanceTimersByTime(5000); - }); - - expect(mockFn).toHaveBeenCalledTimes(2); // Should not have been called again - }); - - it("should set nextRetryAt when scheduling retry", async () => { - mockFn - .mockRejectedValueOnce(new Error("Failure")) - .mockResolvedValueOnce(undefined); - - const { result } = renderHook(() => useWithRetry(mockFn)); - - // Start the call - await act(async () => { - await result.current.call(); - }); - - const nextRetryAt = result.current.nextRetryAt; - expect(nextRetryAt).not.toBe(null); - expect(nextRetryAt).toBeInstanceOf(Date); - - // nextRetryAt should be approximately 1 second in the future - const expectedTime = Date.now() + 1000; - const actualTime = nextRetryAt!.getTime(); - expect(Math.abs(actualTime - expectedTime)).toBeLessThan(100); // Allow 100ms tolerance - - // Advance past retry time - await act(async () => { - jest.advanceTimersByTime(1000); - }); - - expect(result.current.nextRetryAt).toBe(undefined); - }); - - it("should cleanup timer on unmount", async () => { - mockFn.mockRejectedValue(new Error("Failure")); - - const { result, unmount } = renderHook(() => useWithRetry(mockFn)); - - // Start the call to create timer - await act(async () => { - await result.current.call(); - }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.nextRetryAt).not.toBe(null); - - // Unmount should cleanup timer - unmount(); - - // Advance time to ensure timer was cleared - await act(async () => { - jest.advanceTimersByTime(5000); - }); - - // Function should not have been called again - expect(mockFn).toHaveBeenCalledTimes(1); - }); - - it("should prevent scheduling retries when function completes after unmount", async () => { - let rejectPromise: (error: Error) => void; - const promise = new Promise((_, reject) => { - rejectPromise = reject; - }); - mockFn.mockReturnValue(promise); - - const { result, unmount } = renderHook(() => useWithRetry(mockFn)); - - // Start the call - this will make the function in-flight - act(() => { - result.current.call(); - }); - - expect(result.current.isLoading).toBe(true); - - // Unmount while function is still in-flight - unmount(); - - // Function completes with error after unmount - await act(async () => { - rejectPromise!(new Error("Failed after unmount")); - await promise.catch(() => {}); // Suppress unhandled rejection - }); - - // Advance time to ensure no retry timers were scheduled - await act(async () => { - jest.advanceTimersByTime(5000); - }); - - // Function should only have been called once (no retries after unmount) - expect(mockFn).toHaveBeenCalledTimes(1); - }); - - it("should do nothing when call() is invoked while function is already loading", async () => { - let resolvePromise: () => void; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockFn.mockReturnValue(promise); - - const { result } = renderHook(() => useWithRetry(mockFn)); - - // Start the first call - this will set isLoading to true - act(() => { - result.current.call(); - }); - - expect(result.current.isLoading).toBe(true); - expect(mockFn).toHaveBeenCalledTimes(1); - - // Try to call again while loading - should do nothing - act(() => { - result.current.call(); - }); - - // Function should not have been called again - expect(mockFn).toHaveBeenCalledTimes(1); - expect(result.current.isLoading).toBe(true); - - // Complete the original promise - await act(async () => { - resolvePromise!(); - await promise; - }); - - expect(result.current.isLoading).toBe(false); - expect(mockFn).toHaveBeenCalledTimes(1); - }); -}); diff --git a/site/src/hooks/useWithRetry.ts b/site/src/hooks/useWithRetry.ts deleted file mode 100644 index 1310da221efc5..0000000000000 --- a/site/src/hooks/useWithRetry.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useEffectEvent } from "./hookPolyfills"; - -const DELAY_MS = 1_000; -const MAX_DELAY_MS = 600_000; // 10 minutes -// Determines how much the delay between retry attempts increases after each -// failure. -const MULTIPLIER = 2; - -interface UseWithRetryResult { - call: () => void; - nextRetryAt: Date | undefined; - isLoading: boolean; -} - -interface RetryState { - isLoading: boolean; - nextRetryAt: Date | undefined; -} - -/** - * Hook that wraps a function with automatic retry functionality - * Provides a simple interface for executing functions with exponential backoff retry - */ -export function useWithRetry(fn: () => Promise): UseWithRetryResult { - const [state, setState] = useState({ - isLoading: false, - nextRetryAt: undefined, - }); - - const timeoutRef = useRef(null); - const mountedRef = useRef(true); - - const clearTimeout = useCallback(() => { - if (timeoutRef.current) { - window.clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }, []); - - const stableFn = useEffectEvent(fn); - - const call = useCallback(() => { - if (state.isLoading) { - return; - } - - clearTimeout(); - - const executeAttempt = async (attempt = 0): Promise => { - if (!mountedRef.current) { - return; - } - setState({ - isLoading: true, - nextRetryAt: undefined, - }); - - try { - await stableFn(); - if (mountedRef.current) { - setState({ isLoading: false, nextRetryAt: undefined }); - } - } catch (error) { - if (!mountedRef.current) { - return; - } - const delayMs = Math.min( - DELAY_MS * MULTIPLIER ** attempt, - MAX_DELAY_MS, - ); - - setState({ - isLoading: false, - nextRetryAt: new Date(Date.now() + delayMs), - }); - - timeoutRef.current = window.setTimeout(() => { - if (!mountedRef.current) { - return; - } - setState({ - isLoading: false, - nextRetryAt: undefined, - }); - executeAttempt(attempt + 1); - }, delayMs); - } - }; - - executeAttempt(); - }, [state.isLoading, stableFn, clearTimeout]); - - useEffect(() => { - return () => { - mountedRef.current = false; - clearTimeout(); - }; - }, [clearTimeout]); - - return { - call, - nextRetryAt: state.nextRetryAt, - isLoading: state.isLoading, - }; -} diff --git a/site/src/pages/TerminalPage/TerminalAlerts.tsx b/site/src/pages/TerminalPage/TerminalAlerts.tsx index 07740135769f3..6a06a76964128 100644 --- a/site/src/pages/TerminalPage/TerminalAlerts.tsx +++ b/site/src/pages/TerminalPage/TerminalAlerts.tsx @@ -170,14 +170,16 @@ const TerminalAlert: FC = (props) => { ); }; +// Since the terminal connection is always trying to reconnect, we show this +// alert to indicate that the terminal is trying to connect. const DisconnectedAlert: FC = (props) => { return ( } > - Disconnected + Trying to connect... ); }; diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 2023bdb0eeb29..e13fa4e7ba660 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -28,6 +28,9 @@ import { getMatchingAgentOrFirst } from "utils/workspace"; import { v4 as uuidv4 } from "uuid"; import { TerminalAlerts } from "./TerminalAlerts"; import type { ConnectionStatus } from "./types"; +// We use partysocket because it provides automatic reconnection +// and is a drop-in replacement for the native WebSocket API. +import { WebSocket } from "partysocket"; export const Language = { workspaceErrorMessagePrefix: "Unable to fetch workspace: ", From 94ac8eced251371afbbed3774b59df3203b35216 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 8 Jul 2025 14:38:08 +0000 Subject: [PATCH 2/8] Fix fmt --- site/package.json | 6 +----- site/src/pages/TerminalPage/TerminalPage.tsx | 6 +++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/site/package.json b/site/package.json index def934fa55dd1..78359132f6f53 100644 --- a/site/package.json +++ b/site/package.json @@ -191,11 +191,7 @@ "vite-plugin-checker": "0.9.3", "vite-plugin-turbosnap": "1.0.3" }, - "browserslist": [ - "chrome 110", - "firefox 111", - "safari 16.0" - ], + "browserslist": ["chrome 110", "firefox 111", "safari 16.0"], "resolutions": { "optionator": "0.9.3", "semver": "7.6.2" diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index e13fa4e7ba660..ee1e4ebf9487d 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -15,6 +15,9 @@ import { import { useProxy } from "contexts/ProxyContext"; import { ThemeOverride } from "contexts/ThemeProvider"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; +// We use partysocket because it provides automatic reconnection +// and is a drop-in replacement for the native WebSocket API. +import { WebSocket } from "partysocket"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; @@ -28,9 +31,6 @@ import { getMatchingAgentOrFirst } from "utils/workspace"; import { v4 as uuidv4 } from "uuid"; import { TerminalAlerts } from "./TerminalAlerts"; import type { ConnectionStatus } from "./types"; -// We use partysocket because it provides automatic reconnection -// and is a drop-in replacement for the native WebSocket API. -import { WebSocket } from "partysocket"; export const Language = { workspaceErrorMessagePrefix: "Unable to fetch workspace: ", From 012b111e3526fb10ce70018ea1fdaf02e0fd49f6 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 8 Jul 2025 14:41:39 +0000 Subject: [PATCH 3/8] Remove invalid export --- site/src/hooks/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/site/src/hooks/index.ts b/site/src/hooks/index.ts index 4453e36fa4bb4..901fee8a50ded 100644 --- a/site/src/hooks/index.ts +++ b/site/src/hooks/index.ts @@ -3,4 +3,3 @@ export * from "./useClickable"; export * from "./useClickableTableRow"; export * from "./useClipboard"; export * from "./usePagination"; -export * from "./useWithRetry"; From c64a936c5e30de33054d531ecc20ae2f93b611d5 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 8 Jul 2025 16:07:13 +0000 Subject: [PATCH 4/8] Use websocket-ts instead of partysocket --- site/package.json | 2 +- site/pnpm-lock.yaml | 23 +++++++------------ site/src/pages/TerminalPage/TerminalPage.tsx | 24 ++++++++++++-------- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/site/package.json b/site/package.json index 78359132f6f53..e3a99b9d8eebf 100644 --- a/site/package.json +++ b/site/package.json @@ -93,7 +93,6 @@ "lodash": "4.17.21", "lucide-react": "0.474.0", "monaco-editor": "0.52.0", - "partysocket": "1.1.4", "pretty-bytes": "6.1.1", "react": "18.3.1", "react-color": "2.19.3", @@ -121,6 +120,7 @@ "undici": "6.21.2", "unique-names-generator": "4.7.1", "uuid": "9.0.1", + "websocket-ts": "2.2.1", "yup": "1.6.1" }, "devDependencies": { diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index d7f72dd60c5b1..3c7f5176b5b6b 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -193,9 +193,6 @@ importers: monaco-editor: specifier: 0.52.0 version: 0.52.0 - partysocket: - specifier: 1.1.4 - version: 1.1.4 pretty-bytes: specifier: 6.1.1 version: 6.1.1 @@ -277,6 +274,9 @@ importers: uuid: specifier: 9.0.1 version: 9.0.1 + websocket-ts: + specifier: 2.2.1 + version: 2.2.1 yup: specifier: 1.6.1 version: 1.6.1 @@ -3732,9 +3732,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, tarball: https://registry.npmjs.org/etag/-/etag-1.8.1.tgz} engines: {node: '>= 0.6'} - event-target-polyfill@0.0.4: - resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==, tarball: https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz} - eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz} @@ -5112,9 +5109,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, tarball: https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz} engines: {node: '>= 0.8'} - partysocket@1.1.4: - resolution: {integrity: sha512-jXP7PFj2h5/v4UjDS8P7MZy6NJUQ7sspiFyxL4uc/+oKOL+KdtXzHnTV8INPGxBrLTXgalyG3kd12Qm7WrYc3A==, tarball: https://registry.npmjs.org/partysocket/-/partysocket-1.1.4.tgz} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} engines: {node: '>=8'} @@ -6353,6 +6347,9 @@ packages: webpack-virtual-modules@0.5.0: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==, tarball: https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz} + websocket-ts@2.2.1: + resolution: {integrity: sha512-YKPDfxlK5qOheLZ2bTIiktZO1bpfGdNCPJmTEaPW7G9UXI1GKjDdeacOrsULUS000OPNxDVOyAuKLuIWPqWM0Q==, tarball: https://registry.npmjs.org/websocket-ts/-/websocket-ts-2.2.1.tgz} + whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==, tarball: https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz} engines: {node: '>=12'} @@ -10013,8 +10010,6 @@ snapshots: etag@1.8.1: {} - event-target-polyfill@0.0.4: {} - eventemitter3@4.0.7: {} execa@5.1.1: @@ -11979,10 +11974,6 @@ snapshots: parseurl@1.3.3: {} - partysocket@1.1.4: - dependencies: - event-target-polyfill: 0.0.4 - path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -13281,6 +13272,8 @@ snapshots: webpack-virtual-modules@0.5.0: {} + websocket-ts@2.2.1: {} + whatwg-encoding@2.0.0: dependencies: iconv-lite: 0.6.3 diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index ee1e4ebf9487d..bb9e818b4b472 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -15,9 +15,6 @@ import { import { useProxy } from "contexts/ProxyContext"; import { ThemeOverride } from "contexts/ThemeProvider"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; -// We use partysocket because it provides automatic reconnection -// and is a drop-in replacement for the native WebSocket API. -import { WebSocket } from "partysocket"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; @@ -29,6 +26,13 @@ import { openMaybePortForwardedURL } from "utils/portForward"; import { terminalWebsocketUrl } from "utils/terminal"; import { getMatchingAgentOrFirst } from "utils/workspace"; import { v4 as uuidv4 } from "uuid"; +// Use websocket-ts for better WebSocket handling and auto-reconnection. +import { + ConstantBackoff, + type Websocket, + WebsocketBuilder, + WebsocketEvent, +} from "websocket-ts"; import { TerminalAlerts } from "./TerminalAlerts"; import type { ConnectionStatus } from "./types"; @@ -224,7 +228,7 @@ const TerminalPage: FC = () => { } // Hook up terminal events to the websocket. - let websocket: WebSocket | null; + let websocket: Websocket | null; const disposers = [ terminal.onData((data) => { websocket?.send( @@ -262,9 +266,11 @@ const TerminalPage: FC = () => { if (disposed) { return; // Unmounted while we waited for the async call. } - websocket = new WebSocket(url); + websocket = new WebsocketBuilder(url) + .withBackoff(new ConstantBackoff(1000)) + .build(); websocket.binaryType = "arraybuffer"; - websocket.addEventListener("open", () => { + websocket.addEventListener(WebsocketEvent.open, () => { // Now that we are connected, allow user input. terminal.options = { disableStdin: false, @@ -281,18 +287,18 @@ const TerminalPage: FC = () => { ); setConnectionStatus("connected"); }); - websocket.addEventListener("error", () => { + websocket.addEventListener(WebsocketEvent.error, () => { terminal.options.disableStdin = true; terminal.writeln( `${Language.websocketErrorMessagePrefix}socket errored`, ); setConnectionStatus("disconnected"); }); - websocket.addEventListener("close", () => { + websocket.addEventListener(WebsocketEvent.close, () => { terminal.options.disableStdin = true; setConnectionStatus("disconnected"); }); - websocket.addEventListener("message", (event) => { + websocket.addEventListener(WebsocketEvent.message, (_, event) => { if (typeof event.data === "string") { // This exclusively occurs when testing. // "jest-websocket-mock" doesn't support ArrayBuffer. From 16186ce1c732c9771909ad910146e14c35227414 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 8 Jul 2025 17:31:12 +0000 Subject: [PATCH 5/8] Don't print error messages in terminal --- site/src/pages/TerminalPage/TerminalPage.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index bb9e818b4b472..062ccb0aaf027 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -287,11 +287,9 @@ const TerminalPage: FC = () => { ); setConnectionStatus("connected"); }); - websocket.addEventListener(WebsocketEvent.error, () => { + websocket.addEventListener(WebsocketEvent.error, (_, event) => { + console.error("WebSocket error:", event); terminal.options.disableStdin = true; - terminal.writeln( - `${Language.websocketErrorMessagePrefix}socket errored`, - ); setConnectionStatus("disconnected"); }); websocket.addEventListener(WebsocketEvent.close, () => { @@ -312,7 +310,7 @@ const TerminalPage: FC = () => { if (disposed) { return; // Unmounted while we waited for the async call. } - terminal.writeln(Language.websocketErrorMessagePrefix + error.message); + console.error("WebSocket connection failed:", error); setConnectionStatus("disconnected"); }); From f8cb7e70266b50810e3d935fd5e01b9504605a11 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 9 Jul 2025 13:09:32 +0000 Subject: [PATCH 6/8] Fix binary type on reconnect, add exponential backoff, and send the size when reconnect --- site/src/pages/TerminalPage/TerminalPage.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 062ccb0aaf027..5c13e89c30005 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -28,7 +28,7 @@ import { getMatchingAgentOrFirst } from "utils/workspace"; import { v4 as uuidv4 } from "uuid"; // Use websocket-ts for better WebSocket handling and auto-reconnection. import { - ConstantBackoff, + ExponentialBackoff, type Websocket, WebsocketBuilder, WebsocketEvent, @@ -267,7 +267,7 @@ const TerminalPage: FC = () => { return; // Unmounted while we waited for the async call. } websocket = new WebsocketBuilder(url) - .withBackoff(new ConstantBackoff(1000)) + .withBackoff(new ExponentialBackoff(1000, 6)) .build(); websocket.binaryType = "arraybuffer"; websocket.addEventListener(WebsocketEvent.open, () => { @@ -305,6 +305,19 @@ const TerminalPage: FC = () => { terminal.write(new Uint8Array(event.data)); } }); + websocket.addEventListener(WebsocketEvent.reconnect, () => { + if (websocket) { + websocket.binaryType = "arraybuffer"; + websocket.send( + new TextEncoder().encode( + JSON.stringify({ + height: terminal.rows, + width: terminal.cols, + }), + ), + ); + } + }); }) .catch((error) => { if (disposed) { From 09873d21801c071a8b6cc744be0bf5d58aec285b Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 9 Jul 2025 13:17:52 +0000 Subject: [PATCH 7/8] Fix tests --- site/src/pages/TerminalPage/Terminal.tsx | 425 ++++++++++++++++++ .../pages/TerminalPage/TerminalPage.test.tsx | 6 +- 2 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 site/src/pages/TerminalPage/Terminal.tsx diff --git a/site/src/pages/TerminalPage/Terminal.tsx b/site/src/pages/TerminalPage/Terminal.tsx new file mode 100644 index 0000000000000..5c13e89c30005 --- /dev/null +++ b/site/src/pages/TerminalPage/Terminal.tsx @@ -0,0 +1,425 @@ +import "@xterm/xterm/css/xterm.css"; +import type { Interpolation, Theme } from "@emotion/react"; +import { CanvasAddon } from "@xterm/addon-canvas"; +import { FitAddon } from "@xterm/addon-fit"; +import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { WebglAddon } from "@xterm/addon-webgl"; +import { Terminal } from "@xterm/xterm"; +import { deploymentConfig } from "api/queries/deployment"; +import { appearanceSettings } from "api/queries/users"; +import { + workspaceByOwnerAndName, + workspaceUsage, +} from "api/queries/workspaces"; +import { useProxy } from "contexts/ProxyContext"; +import { ThemeOverride } from "contexts/ThemeProvider"; +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; +import { type FC, useCallback, useEffect, useRef, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import themes from "theme"; +import { DEFAULT_TERMINAL_FONT, terminalFonts } from "theme/constants"; +import { pageTitle } from "utils/page"; +import { openMaybePortForwardedURL } from "utils/portForward"; +import { terminalWebsocketUrl } from "utils/terminal"; +import { getMatchingAgentOrFirst } from "utils/workspace"; +import { v4 as uuidv4 } from "uuid"; +// Use websocket-ts for better WebSocket handling and auto-reconnection. +import { + ExponentialBackoff, + type Websocket, + WebsocketBuilder, + WebsocketEvent, +} from "websocket-ts"; +import { TerminalAlerts } from "./TerminalAlerts"; +import type { ConnectionStatus } from "./types"; + +export const Language = { + workspaceErrorMessagePrefix: "Unable to fetch workspace: ", + workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ", + websocketErrorMessagePrefix: "WebSocket failed: ", +}; + +const TerminalPage: FC = () => { + // Maybe one day we'll support a light themed terminal, but terminal coloring + // is notably a pain because of assumptions certain programs might make about your + // background color. + const theme = themes.dark; + const navigate = useNavigate(); + const { proxy, proxyLatencies } = useProxy(); + const params = useParams() as { username: string; workspace: string }; + const username = params.username.replace("@", ""); + const terminalWrapperRef = useRef(null); + // The terminal is maintained as a state to trigger certain effects when it + // updates. + const [terminal, setTerminal] = useState(); + const [connectionStatus, setConnectionStatus] = + useState("initializing"); + const [searchParams] = useSearchParams(); + const isDebugging = searchParams.has("debug"); + // The reconnection token is a unique token that identifies + // a terminal session. It's generated by the client to reduce + // a round-trip, and must be a UUIDv4. + const reconnectionToken = searchParams.get("reconnect") ?? uuidv4(); + const command = searchParams.get("command") || undefined; + const containerName = searchParams.get("container") || undefined; + const containerUser = searchParams.get("container_user") || undefined; + // The workspace name is in the format: + // [.] + const workspaceNameParts = params.workspace?.split("."); + const workspace = useQuery( + workspaceByOwnerAndName(username, workspaceNameParts?.[0]), + ); + const workspaceAgent = workspace.data + ? getMatchingAgentOrFirst(workspace.data, workspaceNameParts?.[1]) + : undefined; + const selectedProxy = proxy.proxy; + const latency = selectedProxy ? proxyLatencies[selectedProxy.id] : undefined; + + const config = useQuery(deploymentConfig()); + const renderer = config.data?.config.web_terminal_renderer; + + // Periodically report workspace usage. + useQuery( + workspaceUsage({ + usageApp: "reconnecting-pty", + connectionStatus, + workspaceId: workspace.data?.id, + agentId: workspaceAgent?.id, + }), + ); + + // handleWebLink handles opening of URLs in the terminal! + const handleWebLink = useCallback( + (uri: string) => { + openMaybePortForwardedURL( + uri, + proxy.preferredWildcardHostname, + workspaceAgent?.name, + workspace.data?.name, + username, + ); + }, + [workspaceAgent, workspace.data, username, proxy.preferredWildcardHostname], + ); + const handleWebLinkRef = useRef(handleWebLink); + useEffect(() => { + handleWebLinkRef.current = handleWebLink; + }, [handleWebLink]); + + const { metadata } = useEmbeddedMetadata(); + const appearanceSettingsQuery = useQuery( + appearanceSettings(metadata.userAppearance), + ); + const currentTerminalFont = + appearanceSettingsQuery.data?.terminal_font || DEFAULT_TERMINAL_FONT; + + // Create the terminal! + const fitAddonRef = useRef(); + useEffect(() => { + if (!terminalWrapperRef.current || config.isLoading) { + return; + } + const terminal = new Terminal({ + allowProposedApi: true, + allowTransparency: true, + disableStdin: false, + fontFamily: terminalFonts[currentTerminalFont], + fontSize: 16, + theme: { + background: theme.palette.background.default, + }, + }); + if (renderer === "webgl") { + terminal.loadAddon(new WebglAddon()); + } else if (renderer === "canvas") { + terminal.loadAddon(new CanvasAddon()); + } + const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; + terminal.loadAddon(fitAddon); + terminal.loadAddon(new Unicode11Addon()); + terminal.unicode.activeVersion = "11"; + terminal.loadAddon( + new WebLinksAddon((_, uri) => { + handleWebLinkRef.current(uri); + }), + ); + + terminal.open(terminalWrapperRef.current); + + // We have to fit twice here. It's unknown why, but the first fit will + // overflow slightly in some scenarios. Applying a second fit resolves this. + fitAddon.fit(); + fitAddon.fit(); + + // This will trigger a resize event on the terminal. + const listener = () => fitAddon.fit(); + window.addEventListener("resize", listener); + + // Terminal is correctly sized and is ready to be used. + setTerminal(terminal); + + return () => { + window.removeEventListener("resize", listener); + terminal.dispose(); + }; + }, [ + config.isLoading, + renderer, + theme.palette.background.default, + currentTerminalFont, + ]); + + // Updates the reconnection token into the URL if necessary. + useEffect(() => { + if (searchParams.get("reconnect") === reconnectionToken) { + return; + } + searchParams.set("reconnect", reconnectionToken); + navigate( + { + search: searchParams.toString(), + }, + { + replace: true, + }, + ); + }, [navigate, reconnectionToken, searchParams]); + + // Hook up the terminal through a web socket. + useEffect(() => { + if (!terminal) { + 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. + terminal.focus(); + + // 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; + } + + if (workspace.error instanceof Error) { + terminal.writeln( + Language.workspaceErrorMessagePrefix + workspace.error.message, + ); + setConnectionStatus("disconnected"); + return; + } + + if (!workspaceAgent) { + terminal.writeln( + `${Language.workspaceAgentErrorMessagePrefix}no agent found with ID, is the workspace started?`, + ); + setConnectionStatus("disconnected"); + 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; + + terminalWebsocketUrl( + // When on development mode we can bypass the proxy and connect directly. + process.env.NODE_ENV !== "development" + ? proxy.preferredPathAppURL + : undefined, + reconnectionToken, + workspaceAgent.id, + command, + terminal.rows, + terminal.cols, + containerName, + containerUser, + ) + .then((url) => { + if (disposed) { + return; // Unmounted while we waited for the async call. + } + websocket = new WebsocketBuilder(url) + .withBackoff(new ExponentialBackoff(1000, 6)) + .build(); + websocket.binaryType = "arraybuffer"; + websocket.addEventListener(WebsocketEvent.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, + }), + ), + ); + setConnectionStatus("connected"); + }); + websocket.addEventListener(WebsocketEvent.error, (_, event) => { + console.error("WebSocket error:", event); + terminal.options.disableStdin = true; + setConnectionStatus("disconnected"); + }); + websocket.addEventListener(WebsocketEvent.close, () => { + terminal.options.disableStdin = true; + setConnectionStatus("disconnected"); + }); + websocket.addEventListener(WebsocketEvent.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)); + } + }); + websocket.addEventListener(WebsocketEvent.reconnect, () => { + if (websocket) { + websocket.binaryType = "arraybuffer"; + websocket.send( + new TextEncoder().encode( + JSON.stringify({ + height: terminal.rows, + width: terminal.cols, + }), + ), + ); + } + }); + }) + .catch((error) => { + if (disposed) { + return; // Unmounted while we waited for the async call. + } + console.error("WebSocket connection failed:", error); + setConnectionStatus("disconnected"); + }); + + return () => { + disposed = true; // Could use AbortController instead? + for (const d of disposers) { + d.dispose(); + } + websocket?.close(1000); + }; + }, [ + command, + proxy.preferredPathAppURL, + reconnectionToken, + terminal, + workspace.error, + workspace.isLoading, + workspaceAgent, + containerName, + containerUser, + ]); + + return ( + + + + {workspace.data + ? pageTitle( + `Terminal · ${workspace.data.owner_name}/${workspace.data.name}`, + ) + : ""} + + +
+ { + fitAddonRef.current?.fit(); + }} + /> +
+
+ + {latency && isDebugging && ( + + Latency: {latency.latencyMS.toFixed(0)}ms + + )} + + ); +}; + +const styles = { + terminal: (theme) => ({ + width: "100%", + overflow: "hidden", + backgroundColor: theme.palette.background.paper, + flex: 1, + // These styles attempt to mimic the VS Code scrollbar. + "& .xterm": { + padding: 4, + width: "100%", + height: "100%", + }, + "& .xterm-viewport": { + // This is required to force full-width on the terminal. + // Otherwise there's a small white bar to the right of the scrollbar. + width: "auto !important", + }, + "& .xterm-viewport::-webkit-scrollbar": { + width: "10px", + }, + "& .xterm-viewport::-webkit-scrollbar-track": { + backgroundColor: "inherit", + }, + "& .xterm-viewport::-webkit-scrollbar-thumb": { + minHeight: 20, + backgroundColor: "rgba(255, 255, 255, 0.18)", + }, + }), +} satisfies Record>; + +export default TerminalPage; diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index 7600fa5257d43..4591190ad9904 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -85,7 +85,7 @@ describe("TerminalPage", () => { await expectTerminalText(container, Language.workspaceErrorMessagePrefix); }); - it("shows an error if the websocket fails", async () => { + it("shows reconnect message when websocket fails", async () => { server.use( http.get("/api/v2/workspaceagents/:agentId/pty", () => { return HttpResponse.json({}, { status: 500 }); @@ -94,7 +94,9 @@ describe("TerminalPage", () => { const { container } = await renderTerminal(); - await expectTerminalText(container, Language.websocketErrorMessagePrefix); + await waitFor(() => { + expect(container.textContent).toContain("Trying to connect..."); + }); }); it("renders data from the backend", async () => { From 7d5c81f7ce73a9d1ec81a337f9353c52328d3d93 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 9 Jul 2025 13:21:22 +0000 Subject: [PATCH 8/8] Remove unecessary file --- site/src/pages/TerminalPage/Terminal.tsx | 425 ----------------------- 1 file changed, 425 deletions(-) delete mode 100644 site/src/pages/TerminalPage/Terminal.tsx diff --git a/site/src/pages/TerminalPage/Terminal.tsx b/site/src/pages/TerminalPage/Terminal.tsx deleted file mode 100644 index 5c13e89c30005..0000000000000 --- a/site/src/pages/TerminalPage/Terminal.tsx +++ /dev/null @@ -1,425 +0,0 @@ -import "@xterm/xterm/css/xterm.css"; -import type { Interpolation, Theme } from "@emotion/react"; -import { CanvasAddon } from "@xterm/addon-canvas"; -import { FitAddon } from "@xterm/addon-fit"; -import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebLinksAddon } from "@xterm/addon-web-links"; -import { WebglAddon } from "@xterm/addon-webgl"; -import { Terminal } from "@xterm/xterm"; -import { deploymentConfig } from "api/queries/deployment"; -import { appearanceSettings } from "api/queries/users"; -import { - workspaceByOwnerAndName, - workspaceUsage, -} from "api/queries/workspaces"; -import { useProxy } from "contexts/ProxyContext"; -import { ThemeOverride } from "contexts/ThemeProvider"; -import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; -import { type FC, useCallback, useEffect, useRef, useState } from "react"; -import { Helmet } from "react-helmet-async"; -import { useQuery } from "react-query"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import themes from "theme"; -import { DEFAULT_TERMINAL_FONT, terminalFonts } from "theme/constants"; -import { pageTitle } from "utils/page"; -import { openMaybePortForwardedURL } from "utils/portForward"; -import { terminalWebsocketUrl } from "utils/terminal"; -import { getMatchingAgentOrFirst } from "utils/workspace"; -import { v4 as uuidv4 } from "uuid"; -// Use websocket-ts for better WebSocket handling and auto-reconnection. -import { - ExponentialBackoff, - type Websocket, - WebsocketBuilder, - WebsocketEvent, -} from "websocket-ts"; -import { TerminalAlerts } from "./TerminalAlerts"; -import type { ConnectionStatus } from "./types"; - -export const Language = { - workspaceErrorMessagePrefix: "Unable to fetch workspace: ", - workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ", - websocketErrorMessagePrefix: "WebSocket failed: ", -}; - -const TerminalPage: FC = () => { - // Maybe one day we'll support a light themed terminal, but terminal coloring - // is notably a pain because of assumptions certain programs might make about your - // background color. - const theme = themes.dark; - const navigate = useNavigate(); - const { proxy, proxyLatencies } = useProxy(); - const params = useParams() as { username: string; workspace: string }; - const username = params.username.replace("@", ""); - const terminalWrapperRef = useRef(null); - // The terminal is maintained as a state to trigger certain effects when it - // updates. - const [terminal, setTerminal] = useState(); - const [connectionStatus, setConnectionStatus] = - useState("initializing"); - const [searchParams] = useSearchParams(); - const isDebugging = searchParams.has("debug"); - // The reconnection token is a unique token that identifies - // a terminal session. It's generated by the client to reduce - // a round-trip, and must be a UUIDv4. - const reconnectionToken = searchParams.get("reconnect") ?? uuidv4(); - const command = searchParams.get("command") || undefined; - const containerName = searchParams.get("container") || undefined; - const containerUser = searchParams.get("container_user") || undefined; - // The workspace name is in the format: - // [.] - const workspaceNameParts = params.workspace?.split("."); - const workspace = useQuery( - workspaceByOwnerAndName(username, workspaceNameParts?.[0]), - ); - const workspaceAgent = workspace.data - ? getMatchingAgentOrFirst(workspace.data, workspaceNameParts?.[1]) - : undefined; - const selectedProxy = proxy.proxy; - const latency = selectedProxy ? proxyLatencies[selectedProxy.id] : undefined; - - const config = useQuery(deploymentConfig()); - const renderer = config.data?.config.web_terminal_renderer; - - // Periodically report workspace usage. - useQuery( - workspaceUsage({ - usageApp: "reconnecting-pty", - connectionStatus, - workspaceId: workspace.data?.id, - agentId: workspaceAgent?.id, - }), - ); - - // handleWebLink handles opening of URLs in the terminal! - const handleWebLink = useCallback( - (uri: string) => { - openMaybePortForwardedURL( - uri, - proxy.preferredWildcardHostname, - workspaceAgent?.name, - workspace.data?.name, - username, - ); - }, - [workspaceAgent, workspace.data, username, proxy.preferredWildcardHostname], - ); - const handleWebLinkRef = useRef(handleWebLink); - useEffect(() => { - handleWebLinkRef.current = handleWebLink; - }, [handleWebLink]); - - const { metadata } = useEmbeddedMetadata(); - const appearanceSettingsQuery = useQuery( - appearanceSettings(metadata.userAppearance), - ); - const currentTerminalFont = - appearanceSettingsQuery.data?.terminal_font || DEFAULT_TERMINAL_FONT; - - // Create the terminal! - const fitAddonRef = useRef(); - useEffect(() => { - if (!terminalWrapperRef.current || config.isLoading) { - return; - } - const terminal = new Terminal({ - allowProposedApi: true, - allowTransparency: true, - disableStdin: false, - fontFamily: terminalFonts[currentTerminalFont], - fontSize: 16, - theme: { - background: theme.palette.background.default, - }, - }); - if (renderer === "webgl") { - terminal.loadAddon(new WebglAddon()); - } else if (renderer === "canvas") { - terminal.loadAddon(new CanvasAddon()); - } - const fitAddon = new FitAddon(); - fitAddonRef.current = fitAddon; - terminal.loadAddon(fitAddon); - terminal.loadAddon(new Unicode11Addon()); - terminal.unicode.activeVersion = "11"; - terminal.loadAddon( - new WebLinksAddon((_, uri) => { - handleWebLinkRef.current(uri); - }), - ); - - terminal.open(terminalWrapperRef.current); - - // We have to fit twice here. It's unknown why, but the first fit will - // overflow slightly in some scenarios. Applying a second fit resolves this. - fitAddon.fit(); - fitAddon.fit(); - - // This will trigger a resize event on the terminal. - const listener = () => fitAddon.fit(); - window.addEventListener("resize", listener); - - // Terminal is correctly sized and is ready to be used. - setTerminal(terminal); - - return () => { - window.removeEventListener("resize", listener); - terminal.dispose(); - }; - }, [ - config.isLoading, - renderer, - theme.palette.background.default, - currentTerminalFont, - ]); - - // Updates the reconnection token into the URL if necessary. - useEffect(() => { - if (searchParams.get("reconnect") === reconnectionToken) { - return; - } - searchParams.set("reconnect", reconnectionToken); - navigate( - { - search: searchParams.toString(), - }, - { - replace: true, - }, - ); - }, [navigate, reconnectionToken, searchParams]); - - // Hook up the terminal through a web socket. - useEffect(() => { - if (!terminal) { - 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. - terminal.focus(); - - // 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; - } - - if (workspace.error instanceof Error) { - terminal.writeln( - Language.workspaceErrorMessagePrefix + workspace.error.message, - ); - setConnectionStatus("disconnected"); - return; - } - - if (!workspaceAgent) { - terminal.writeln( - `${Language.workspaceAgentErrorMessagePrefix}no agent found with ID, is the workspace started?`, - ); - setConnectionStatus("disconnected"); - 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; - - terminalWebsocketUrl( - // When on development mode we can bypass the proxy and connect directly. - process.env.NODE_ENV !== "development" - ? proxy.preferredPathAppURL - : undefined, - reconnectionToken, - workspaceAgent.id, - command, - terminal.rows, - terminal.cols, - containerName, - containerUser, - ) - .then((url) => { - if (disposed) { - return; // Unmounted while we waited for the async call. - } - websocket = new WebsocketBuilder(url) - .withBackoff(new ExponentialBackoff(1000, 6)) - .build(); - websocket.binaryType = "arraybuffer"; - websocket.addEventListener(WebsocketEvent.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, - }), - ), - ); - setConnectionStatus("connected"); - }); - websocket.addEventListener(WebsocketEvent.error, (_, event) => { - console.error("WebSocket error:", event); - terminal.options.disableStdin = true; - setConnectionStatus("disconnected"); - }); - websocket.addEventListener(WebsocketEvent.close, () => { - terminal.options.disableStdin = true; - setConnectionStatus("disconnected"); - }); - websocket.addEventListener(WebsocketEvent.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)); - } - }); - websocket.addEventListener(WebsocketEvent.reconnect, () => { - if (websocket) { - websocket.binaryType = "arraybuffer"; - websocket.send( - new TextEncoder().encode( - JSON.stringify({ - height: terminal.rows, - width: terminal.cols, - }), - ), - ); - } - }); - }) - .catch((error) => { - if (disposed) { - return; // Unmounted while we waited for the async call. - } - console.error("WebSocket connection failed:", error); - setConnectionStatus("disconnected"); - }); - - return () => { - disposed = true; // Could use AbortController instead? - for (const d of disposers) { - d.dispose(); - } - websocket?.close(1000); - }; - }, [ - command, - proxy.preferredPathAppURL, - reconnectionToken, - terminal, - workspace.error, - workspace.isLoading, - workspaceAgent, - containerName, - containerUser, - ]); - - return ( - - - - {workspace.data - ? pageTitle( - `Terminal · ${workspace.data.owner_name}/${workspace.data.name}`, - ) - : ""} - - -
- { - fitAddonRef.current?.fit(); - }} - /> -
-
- - {latency && isDebugging && ( - - Latency: {latency.latencyMS.toFixed(0)}ms - - )} - - ); -}; - -const styles = { - terminal: (theme) => ({ - width: "100%", - overflow: "hidden", - backgroundColor: theme.palette.background.paper, - flex: 1, - // These styles attempt to mimic the VS Code scrollbar. - "& .xterm": { - padding: 4, - width: "100%", - height: "100%", - }, - "& .xterm-viewport": { - // This is required to force full-width on the terminal. - // Otherwise there's a small white bar to the right of the scrollbar. - width: "auto !important", - }, - "& .xterm-viewport::-webkit-scrollbar": { - width: "10px", - }, - "& .xterm-viewport::-webkit-scrollbar-track": { - backgroundColor: "inherit", - }, - "& .xterm-viewport::-webkit-scrollbar-thumb": { - minHeight: 20, - backgroundColor: "rgba(255, 255, 255, 0.18)", - }, - }), -} satisfies Record>; - -export default TerminalPage;