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; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy