Part 2 of the browser terminal series. Add professional UI with theme switching, settings persistence, connection status indicators, and session timers to your xterm.js terminal.
terminal-xterm-websocket v2.0.0
Complete source code for Part 2 - production UI with themes and session management
Part 1 Source (v1.0.0)
Basic terminal implementation from Part 1
Building on Part 1
In Part 1, we built a functional browser terminal with xterm.js, WebSockets, and node-pty. Now we'll transform it into a production-ready application with:
Dependencies
Before we start, install the required packages:
Here's what we're building - a polished terminal with theme switching and settings:
Project Structure
After this section, your project will look like this:
Part 1: Theme System
Let's start with a proper theme system. We'll define multiple color schemes that users can switch between.
Step 1: Create Theme Definitions
Create src/config/themes.ts:
1/**2 * Terminal Theme Definitions3 *4 * Each theme provides colors for xterm.js terminal emulator.5 * Themes are stored in localStorage for persistence.6 */78export interface TerminalTheme {9 id: string;10 name: string;11 colors: {12 background: string;13 foreground: string;14 cursor: string;15 cursorAccent: string;16 selectionBackground: string;17 black: string;18 red: string;19 green: string;20 yellow: string;21 blue: string;22 magenta: string;23 cyan: string;24 white: string;25 brightBlack: string;26 brightRed: string;27 brightGreen: string;28 brightYellow: string;29 brightBlue: string;30 brightMagenta: string;31 brightCyan: string;32 brightWhite: string;33 };34}3536export const THEMES: TerminalTheme[] = [37 {38 id: 'tokyo-night',39 name: 'Tokyo Night',40 colors: {41 background: '#1a1b26',42 foreground: '#a9b1d6',43 cursor: '#c0caf5',44 cursorAccent: '#1a1b26',45 selectionBackground: '#283457',46 black: '#15161e',47 red: '#f7768e',48 green: '#9ece6a',49 yellow: '#e0af68',50 blue: '#7aa2f7',51 magenta: '#bb9af7',52 cyan: '#7dcfff',53 white: '#a9b1d6',54 brightBlack: '#414868',55 brightRed: '#f7768e',56 brightGreen: '#9ece6a',57 brightYellow: '#e0af68',58 brightBlue: '#7aa2f7',59 brightMagenta: '#bb9af7',60 brightCyan: '#7dcfff',61 brightWhite: '#c0caf5',62 },63 },64 {65 id: 'dracula',66 name: 'Dracula',67 colors: {68 background: '#282a36',69 foreground: '#f8f8f2',70 cursor: '#f8f8f2',71 cursorAccent: '#282a36',72 selectionBackground: '#44475a',73 black: '#21222c',74 red: '#ff5555',75 green: '#50fa7b',76 yellow: '#f1fa8c',77 blue: '#bd93f9',78 magenta: '#ff79c6',79 cyan: '#8be9fd',80 white: '#f8f8f2',81 brightBlack: '#6272a4',82 brightRed: '#ff6e6e',83 brightGreen: '#69ff94',84 brightYellow: '#ffffa5',85 brightBlue: '#d6acff',86 brightMagenta: '#ff92df',87 brightCyan: '#a4ffff',88 brightWhite: '#ffffff',89 },90 },91 {92 id: 'github-dark',93 name: 'GitHub Dark',94 colors: {95 background: '#0d1117',96 foreground: '#c9d1d9',97 cursor: '#c9d1d9',98 cursorAccent: '#0d1117',99 selectionBackground: '#264f78',100 black: '#484f58',101 red: '#ff7b72',102 green: '#7ee787',103 yellow: '#d29922',104 blue: '#58a6ff',105 magenta: '#bc8cff',106 cyan: '#76e3ea',107 white: '#b1bac4',108 brightBlack: '#6e7681',109 brightRed: '#ffa198',110 brightGreen: '#a5d6ff',111 brightYellow: '#f0b05c',112 brightBlue: '#80ccff',113 brightMagenta: '#d8bcf0',114 brightCyan: '#96e0f0',115 brightWhite: '#f0f6fc',116 },117 },118 {119 id: 'monokai',120 name: 'Monokai Pro',121 colors: {122 background: '#2d2a2e',123 foreground: '#fcfcfa',124 cursor: '#fcfcfa',125 cursorAccent: '#2d2a2e',126 selectionBackground: '#5b595c',127 black: '#2d2a2e',128 red: '#ff6188',129 green: '#a9dc76',130 yellow: '#ffd866',131 blue: '#78dce8',132 magenta: '#ab9df2',133 cyan: '#78dce8',134 white: '#fcfcfa',135 brightBlack: '#727072',136 brightRed: '#ff6188',137 brightGreen: '#a9dc76',138 brightYellow: '#ffd866',139 brightBlue: '#78dce8',140 brightMagenta: '#ab9df2',141 brightCyan: '#78dce8',142 brightWhite: '#fcfcfa',143 },144 },145 {146 id: 'synthwave',147 name: 'Synthwave',148 colors: {149 background: '#2b213a',150 foreground: '#f0eff1',151 cursor: '#ff7edb',152 cursorAccent: '#2b213a',153 selectionBackground: '#463465',154 black: '#2b213a',155 red: '#fe4450',156 green: '#72f1b8',157 yellow: '#fede5d',158 blue: '#03edf9',159 magenta: '#ff7edb',160 cyan: '#03edf9',161 white: '#f0eff1',162 brightBlack: '#614d85',163 brightRed: '#fe4450',164 brightGreen: '#72f1b8',165 brightYellow: '#fede5d',166 brightBlue: '#03edf9',167 brightMagenta: '#ff7edb',168 brightCyan: '#03edf9',169 brightWhite: '#ffffff',170 },171 },172 {173 id: 'retro-green',174 name: 'Retro Green',175 colors: {176 background: '#0a0a0a',177 foreground: '#00ff00',178 cursor: '#00ff00',179 cursorAccent: '#0a0a0a',180 selectionBackground: '#003300',181 black: '#0a0a0a',182 red: '#ff0000',183 green: '#00ff00',184 yellow: '#ffff00',185 blue: '#0000ff',186 magenta: '#ff00ff',187 cyan: '#00ffff',188 white: '#ffffff',189 brightBlack: '#555555',190 brightRed: '#ff5555',191 brightGreen: '#55ff55',192 brightYellow: '#ffff55',193 brightBlue: '#5555ff',194 brightMagenta: '#ff55ff',195 brightCyan: '#55ffff',196 brightWhite: '#ffffff',197 },198 },199];200201export const DEFAULT_THEME_ID = 'tokyo-night';202203export function getThemeById(id: string): TerminalTheme {204 return THEMES.find((t) => t.id === id) || THEMES[0];205}Step 2: Create Theme Hook
Create src/hooks/useTerminalTheme.ts:
1import { useState, useEffect, useCallback } from 'react';2import { THEMES, DEFAULT_THEME_ID, getThemeById, type TerminalTheme } from '../config/themes';34const STORAGE_KEY = 'terminal-theme';56interface UseTerminalThemeReturn {7 theme: TerminalTheme;8 themes: TerminalTheme[];9 setThemeById: (id: string) => void;10}1112export function useTerminalTheme(): UseTerminalThemeReturn {13 const [theme, setTheme] = useState<TerminalTheme>(() => {14 if (typeof window !== 'undefined') {15 const saved = localStorage.getItem(STORAGE_KEY);16 if (saved) {17 return getThemeById(saved);18 }19 }20 return getThemeById(DEFAULT_THEME_ID);21 });2223 // Persist to localStorage24 useEffect(() => {25 localStorage.setItem(STORAGE_KEY, theme.id);26 }, [theme]);2728 const setThemeById = useCallback((id: string) => {29 setTheme(getThemeById(id));30 }, []);3132 return {33 theme,34 themes: THEMES,35 setThemeById,36 };37}Part 2: Settings System
Now let's add user-configurable settings like font size and cursor behavior.
Step 3: Create Settings Hook
Create src/hooks/useTerminalSettings.ts:
1import { useState, useEffect, useCallback } from 'react';23const STORAGE_KEY = 'terminal-settings';45export interface TerminalSettings {6 fontSize: number;7 fontFamily: string;8 cursorBlink: boolean;9}1011const DEFAULT_SETTINGS: TerminalSettings = {12 fontSize: 14,13 fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',14 cursorBlink: true,15};1617interface UseTerminalSettingsReturn extends TerminalSettings {18 setFontSize: (size: number) => void;19 setFontFamily: (family: string) => void;20 setCursorBlink: (blink: boolean) => void;21 resetToDefaults: () => void;22}2324export function useTerminalSettings(): UseTerminalSettingsReturn {25 const [settings, setSettings] = useState<TerminalSettings>(() => {26 if (typeof window !== 'undefined') {27 try {28 const saved = localStorage.getItem(STORAGE_KEY);29 if (saved) {30 const parsed = JSON.parse(saved);31 return { ...DEFAULT_SETTINGS, ...parsed };32 }33 } catch {34 // Invalid JSON, use defaults35 }36 }37 return DEFAULT_SETTINGS;38 });3940 // Persist to localStorage41 useEffect(() => {42 localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));43 }, [settings]);4445 const setFontSize = useCallback((size: number) => {46 // Clamp between 10 and 2447 const clamped = Math.max(10, Math.min(24, size));48 setSettings((s) => ({ ...s, fontSize: clamped }));49 }, []);5051 const setFontFamily = useCallback((family: string) => {52 setSettings((s) => ({ ...s, fontFamily: family }));53 }, []);5455 const setCursorBlink = useCallback((blink: boolean) => {56 setSettings((s) => ({ ...s, cursorBlink: blink }));57 }, []);5859 const resetToDefaults = useCallback(() => {60 setSettings(DEFAULT_SETTINGS);61 }, []);6263 return {64 ...settings,65 setFontSize,66 setFontFamily,67 setCursorBlink,68 resetToDefaults,69 };70}Part 3: UI Components
Now let's build the toolbar components that display status and controls.
Step 4: Status Indicator
Create src/components/StatusIndicator.tsx:
1interface StatusIndicatorProps {2 isConnected: boolean;3 isReconnecting: boolean;4 reconnectAttempt?: number;5}67export function StatusIndicator({8 isConnected,9 isReconnecting,10 reconnectAttempt,11}: StatusIndicatorProps) {12 const getStatus = () => {13 if (isConnected) {14 return { color: '#22c55e', label: 'Connected', pulse: false };15 }16 if (isReconnecting) {17 return {18 color: '#eab308',19 label: `Reconnecting${reconnectAttempt ? ` (${reconnectAttempt})` : ''}`,20 pulse: true,21 };22 }23 return { color: '#ef4444', label: 'Disconnected', pulse: false };24 };2526 const { color, label, pulse } = getStatus();2728 return (29 <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>30 <div31 style={{32 width: '8px',33 height: '8px',34 borderRadius: '50%',35 backgroundColor: color,36 boxShadow: pulse ? `0 0 8px ${color}` : 'none',37 animation: pulse ? 'pulse 1.5s ease-in-out infinite' : 'none',38 }}39 />40 <span style={{ fontSize: '12px', fontFamily: 'monospace', color: '#9ca3af' }}>41 {label}42 </span>43 <style>{`44 @keyframes pulse {45 0%, 100% { opacity: 1; }46 50% { opacity: 0.5; }47 }48 `}</style>49 </div>50 );51}Step 5: Session Timer
Create src/components/SessionTimer.tsx:
1import { useState, useEffect } from 'react';23interface SessionTimerProps {4 expiresAt?: Date;5 onExpired?: () => void;6}78export function SessionTimer({ expiresAt, onExpired }: SessionTimerProps) {9 const [timeLeft, setTimeLeft] = useState<string>('');10 const [isExpired, setIsExpired] = useState(false);11 const [isWarning, setIsWarning] = useState(false);1213 useEffect(() => {14 if (!expiresAt) return;1516 const updateTimer = () => {17 const now = new Date();18 const diff = expiresAt.getTime() - now.getTime();1920 if (diff <= 0) {21 setTimeLeft('Expired');22 setIsExpired(true);23 onExpired?.();24 return;25 }2627 // Warning when less than 2 minutes left28 setIsWarning(diff < 2 * 60 * 1000);2930 const minutes = Math.floor(diff / 60000);31 const seconds = Math.floor((diff % 60000) / 1000);32 setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`);33 };3435 updateTimer();36 const interval = setInterval(updateTimer, 1000);37 return () => clearInterval(interval);38 }, [expiresAt, onExpired]);3940 if (!expiresAt) return null;4142 const getColor = () => {43 if (isExpired) return '#ef4444';44 if (isWarning) return '#eab308';45 return '#9ca3af';46 };4748 return (49 <div50 style={{51 display: 'flex',52 alignItems: 'center',53 gap: '6px',54 fontSize: '12px',55 fontFamily: 'monospace',56 color: getColor(),57 }}58 title="Session time remaining"59 >60 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">61 <circle cx="12" cy="12" r="10" />62 <polyline points="12 6 12 12 16 14" />63 </svg>64 <span>{timeLeft}</span>65 </div>66 );67}Step 6: Theme Selector
Create src/components/ThemeSelector.tsx:
1import { useState, useRef, useEffect } from 'react';2import type { TerminalTheme } from '../config/themes';34interface ThemeSelectorProps {5 themes: TerminalTheme[];6 currentTheme: TerminalTheme;7 onThemeChange: (themeId: string) => void;8}910export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSelectorProps) {11 const [isOpen, setIsOpen] = useState(false);12 const dropdownRef = useRef<HTMLDivElement>(null);1314 // Close dropdown when clicking outside15 useEffect(() => {16 const handleClickOutside = (event: MouseEvent) => {17 if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {18 setIsOpen(false);19 }20 };21 document.addEventListener('mousedown', handleClickOutside);22 return () => document.removeEventListener('mousedown', handleClickOutside);23 }, []);2425 return (26 <div ref={dropdownRef} style={{ position: 'relative' }}>27 <button28 onClick={() => setIsOpen(!isOpen)}29 style={{30 display: 'flex',31 alignItems: 'center',32 gap: '8px',33 padding: '6px 12px',34 backgroundColor: 'rgba(255, 255, 255, 0.1)',35 border: '1px solid rgba(255, 255, 255, 0.2)',36 borderRadius: '4px',37 color: '#e5e5e5',38 fontSize: '12px',39 fontFamily: 'monospace',40 cursor: 'pointer',41 }}42 >43 <div44 style={{45 width: '12px',46 height: '12px',47 borderRadius: '2px',48 backgroundColor: currentTheme.colors.background,49 border: '1px solid rgba(255, 255, 255, 0.3)',50 }}51 />52 {currentTheme.name}53 </button>5455 {isOpen && (56 <div57 style={{58 position: 'absolute',59 top: 'calc(100% + 4px)',60 right: 0,61 minWidth: '180px',62 backgroundColor: '#1f1f1f',63 border: '1px solid rgba(255, 255, 255, 0.2)',64 borderRadius: '4px',65 boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',66 zIndex: 100,67 }}68 >69 {themes.map((theme) => (70 <button71 key={theme.id}72 onClick={() => {73 onThemeChange(theme.id);74 setIsOpen(false);75 }}76 style={{77 display: 'flex',78 alignItems: 'center',79 gap: '10px',80 width: '100%',81 padding: '10px 12px',82 backgroundColor: theme.id === currentTheme.id ? 'rgba(255, 255, 255, 0.1)' : 'transparent',83 border: 'none',84 color: '#e5e5e5',85 fontSize: '12px',86 fontFamily: 'monospace',87 cursor: 'pointer',88 textAlign: 'left',89 }}90 >91 <div92 style={{93 width: '16px',94 height: '16px',95 borderRadius: '2px',96 backgroundColor: theme.colors.background,97 border: '1px solid rgba(255, 255, 255, 0.2)',98 }}99 />100 {theme.name}101 </button>102 ))}103 </div>104 )}105 </div>106 );107}Step 7: Settings Panel
Create src/components/SettingsPanel.tsx:
1import { useState, useRef, useEffect } from 'react';2import type { TerminalSettings } from '../hooks/useTerminalSettings';34interface SettingsPanelProps {5 settings: TerminalSettings;6 onFontSizeChange: (size: number) => void;7 onCursorBlinkChange: (blink: boolean) => void;8 onReset: () => void;9}1011export function SettingsPanel({12 settings,13 onFontSizeChange,14 onCursorBlinkChange,15 onReset,16}: SettingsPanelProps) {17 const [isOpen, setIsOpen] = useState(false);18 const panelRef = useRef<HTMLDivElement>(null);1920 useEffect(() => {21 const handleClickOutside = (event: MouseEvent) => {22 if (panelRef.current && !panelRef.current.contains(event.target as Node)) {23 setIsOpen(false);24 }25 };26 document.addEventListener('mousedown', handleClickOutside);27 return () => document.removeEventListener('mousedown', handleClickOutside);28 }, []);2930 return (31 <div ref={panelRef} style={{ position: 'relative' }}>32 <button33 onClick={() => setIsOpen(!isOpen)}34 style={{35 display: 'flex',36 alignItems: 'center',37 justifyContent: 'center',38 width: '32px',39 height: '32px',40 backgroundColor: 'rgba(255, 255, 255, 0.1)',41 border: '1px solid rgba(255, 255, 255, 0.2)',42 borderRadius: '4px',43 color: '#e5e5e5',44 cursor: 'pointer',45 }}46 title="Settings"47 >48 {/* Gear icon SVG */}49 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">50 <circle cx="12" cy="12" r="3" />51 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />52 </svg>53 </button>5455 {isOpen && (56 <div57 style={{58 position: 'absolute',59 top: 'calc(100% + 4px)',60 right: 0,61 width: '220px',62 backgroundColor: '#1f1f1f',63 border: '1px solid rgba(255, 255, 255, 0.2)',64 borderRadius: '4px',65 padding: '12px',66 zIndex: 100,67 }}68 >69 {/* Font Size */}70 <div style={{ marginBottom: '16px' }}>71 <label style={{ display: 'block', fontSize: '11px', color: '#9ca3af', marginBottom: '6px' }}>72 FONT SIZE73 </label>74 <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>75 <button onClick={() => onFontSizeChange(settings.fontSize - 1)}>-</button>76 <span style={{ flex: 1, textAlign: 'center', color: '#e5e5e5' }}>{settings.fontSize}px</span>77 <button onClick={() => onFontSizeChange(settings.fontSize + 1)}>+</button>78 </div>79 </div>8081 {/* Cursor Blink */}82 <div style={{ marginBottom: '16px' }}>83 <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>84 <input85 type="checkbox"86 checked={settings.cursorBlink}87 onChange={(e) => onCursorBlinkChange(e.target.checked)}88 />89 <span style={{ fontSize: '12px', color: '#e5e5e5' }}>Cursor Blink</span>90 </label>91 </div>9293 {/* Reset */}94 <button onClick={onReset} style={{ width: '100%', padding: '8px', fontSize: '11px' }}>95 Reset to Defaults96 </button>97 </div>98 )}99 </div>100 );101}Step 8: Terminal Toolbar
Create src/components/TerminalToolbar.tsx to combine all controls:
1import { StatusIndicator } from './StatusIndicator';2import { SessionTimer } from './SessionTimer';3import { ThemeSelector } from './ThemeSelector';4import { SettingsPanel } from './SettingsPanel';5import type { TerminalTheme } from '../config/themes';6import type { TerminalSettings } from '../hooks/useTerminalSettings';78interface TerminalToolbarProps {9 isConnected: boolean;10 isReconnecting: boolean;11 reconnectAttempt?: number;12 sessionExpiresAt?: Date;13 onSessionExpired?: () => void;14 themes: TerminalTheme[];15 currentTheme: TerminalTheme;16 onThemeChange: (themeId: string) => void;17 settings: TerminalSettings;18 onFontSizeChange: (size: number) => void;19 onCursorBlinkChange: (blink: boolean) => void;20 onSettingsReset: () => void;21}2223export function TerminalToolbar(props: TerminalToolbarProps) {24 return (25 <div26 style={{27 display: 'flex',28 alignItems: 'center',29 justifyContent: 'space-between',30 padding: '8px 12px',31 backgroundColor: '#0f0f0f',32 borderBottom: '1px solid rgba(255, 255, 255, 0.1)',33 }}34 >35 {/* Left: Status + Timer */}36 <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>37 <StatusIndicator38 isConnected={props.isConnected}39 isReconnecting={props.isReconnecting}40 reconnectAttempt={props.reconnectAttempt}41 />42 <SessionTimer expiresAt={props.sessionExpiresAt} onExpired={props.onSessionExpired} />43 </div>4445 {/* Right: Theme + Settings */}46 <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>47 <ThemeSelector48 themes={props.themes}49 currentTheme={props.currentTheme}50 onThemeChange={props.onThemeChange}51 />52 <SettingsPanel53 settings={props.settings}54 onFontSizeChange={props.onFontSizeChange}55 onCursorBlinkChange={props.onCursorBlinkChange}56 onReset={props.onSettingsReset}57 />58 </div>59 </div>60 );61}Part 4: Update Terminal Component
Now update src/components/Terminal.tsx to accept theme and settings props:
1import { useEffect, useRef, useCallback, useState } from 'react';2import { Terminal as XTerm } from 'xterm';3import { FitAddon } from '@xterm/addon-fit';4import { WebLinksAddon } from '@xterm/addon-web-links';5import { useWebSocket } from '../hooks/useWebSocket';6import type { TerminalTheme } from '../config/themes';7import type { TerminalSettings } from '../hooks/useTerminalSettings';8import 'xterm/css/xterm.css';910interface TerminalProps {11 wsUrl: string;12 theme: TerminalTheme;13 settings: TerminalSettings;14 onConnectionChange?: (connected: boolean) => void;15 onReconnecting?: (attempt: number, maxAttempts: number) => void;16}1718export function Terminal({ wsUrl, theme, settings, onConnectionChange, onReconnecting }: TerminalProps) {19 // ... existing terminal initialization code ...2021 // Update theme when it changes22 useEffect(() => {23 const term = xtermRef.current;24 if (term) {25 term.options.theme = theme.colors;26 }27 }, [theme]);2829 // Update settings when they change30 useEffect(() => {31 const term = xtermRef.current;32 const fitAddon = fitAddonRef.current;33 if (term) {34 term.options.fontSize = settings.fontSize;35 term.options.fontFamily = settings.fontFamily;36 term.options.cursorBlink = settings.cursorBlink;3738 // Refit after font size change39 if (fitAddon) {40 requestAnimationFrame(() => {41 fitAddon.fit();42 const { cols, rows } = term;43 send(JSON.stringify({ type: 'resize', cols, rows }));44 });45 }46 }47 }, [settings.fontSize, settings.fontFamily, settings.cursorBlink, send]);4849 // ... rest of component ...50}Part 5: Wire It All Together
Finally, update src/App.tsx:
1import { useState, useCallback } from 'react';2import { Terminal } from './components/Terminal';3import { TerminalToolbar } from './components/TerminalToolbar';4import { useTerminalTheme } from './hooks/useTerminalTheme';5import { useTerminalSettings } from './hooks/useTerminalSettings';67const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3001';89function App() {10 const [isConnected, setIsConnected] = useState(false);11 const [isReconnecting, setIsReconnecting] = useState(false);12 const [reconnectAttempt, setReconnectAttempt] = useState(0);1314 const { theme, themes, setThemeById } = useTerminalTheme();15 const terminalSettings = useTerminalSettings();1617 // Demo session expiry (30 minutes from now)18 const [sessionExpiresAt] = useState<Date | undefined>(19 () => new Date(Date.now() + 30 * 60 * 1000)20 );2122 const handleConnectionChange = useCallback((connected: boolean) => {23 setIsConnected(connected);24 if (connected) {25 setIsReconnecting(false);26 setReconnectAttempt(0);27 }28 }, []);2930 const handleReconnecting = useCallback((attempt: number) => {31 setIsReconnecting(true);32 setReconnectAttempt(attempt);33 }, []);3435 return (36 <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', backgroundColor: '#0a0a0a' }}>37 <TerminalToolbar38 isConnected={isConnected}39 isReconnecting={isReconnecting}40 reconnectAttempt={reconnectAttempt}41 sessionExpiresAt={sessionExpiresAt}42 themes={themes}43 currentTheme={theme}44 onThemeChange={setThemeById}45 settings={terminalSettings}46 onFontSizeChange={terminalSettings.setFontSize}47 onCursorBlinkChange={terminalSettings.setCursorBlink}48 onSettingsReset={terminalSettings.resetToDefaults}49 />50 <div style={{ flex: 1, overflow: 'hidden' }}>51 <Terminal52 wsUrl={wsUrl}53 theme={theme}54 settings={terminalSettings}55 onConnectionChange={handleConnectionChange}56 onReconnecting={handleReconnecting}57 />58 </div>59 </div>60 );61}6263export default App;Testing Your Changes
Start the backend and frontend:
Open http://localhost:5173 and test:
Section 2: JWT Session Tokens
Now let's make the session timer functional. We'll add JWT-based authentication that:
- Generates tokens with expiry times
- Validates tokens on WebSocket connection
- Auto-disconnects when tokens expire
- Shows an expiry overlay with "Request New Session" button
Backend: Token Generation
First, install the JWT dependencies in your server:
Update your docker-compose.yml to pass environment variables:
1services:2 backend:3 build:4 context: ./server5 dockerfile: Dockerfile6 ports:7 - "3001:3001"8 environment:9 - JWT_SECRET=${JWT_SECRET:-dev-secret-change-in-production}10 - SESSION_DURATION_MS=${SESSION_DURATION_MS:-300000}11 tty: true12 stdin_open: true13 restart: unless-stoppedAdd to your .env file:
1# Backend Configuration2JWT_SECRET=your-super-secret-key-change-this3SESSION_DURATION_MS=300000 # 5 minutes (use 30000 for 30s testing)Backend: Updated Server with JWT
Replace server/index.ts with JWT authentication:
1import { WebSocketServer, WebSocket } from 'ws';2import * as pty from 'node-pty';3import { platform } from 'os';4import { accessSync } from 'fs';5import { createServer, IncomingMessage, ServerResponse } from 'http';6import { URL } from 'url';7import jwt from 'jsonwebtoken';8import { v4 as uuidv4 } from 'uuid';910// Configuration from environment11const PORT = parseInt(process.env.PORT || '3001', 10);12const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';13const SESSION_DURATION_MS = parseInt(process.env.SESSION_DURATION_MS || '300000', 10);14const HEARTBEAT_INTERVAL = 30000;1516// Token payload interface17interface TokenPayload {18 sessionId: string;19 expiresAt: number;20 iat: number;21}2223// Session tracking24interface Session {25 sessionId: string;26 ws: WebSocket;27 ptyProcess: pty.IPty;28 expiresAt: number;29 expiryTimeout: NodeJS.Timeout;30 heartbeat: NodeJS.Timeout;31}3233const sessions = new Map<string, Session>();3435// Shell detection (same as before)36const getShell = () => {37 if (process.env.SHELL_PATH) return process.env.SHELL_PATH;38 if (platform() === 'win32') return 'powershell.exe';39 try {40 accessSync('/bin/bash');41 return '/bin/bash';42 } catch {43 return '/bin/sh';44 }45};46const shell = getShell();4748// Generate JWT token49function generateToken(): { token: string; expiresAt: number; sessionId: string } {50 const sessionId = uuidv4();51 const expiresAt = Date.now() + SESSION_DURATION_MS;5253 const token = jwt.sign(54 { sessionId, expiresAt, iat: Date.now() },55 JWT_SECRET,56 { expiresIn: Math.floor(SESSION_DURATION_MS / 1000) }57 );5859 return { token, expiresAt, sessionId };60}6162// Verify JWT token63function verifyToken(token: string): TokenPayload | null {64 try {65 const payload = jwt.verify(token, JWT_SECRET) as TokenPayload;66 if (payload.expiresAt < Date.now()) return null;67 return payload;68 } catch {69 return null;70 }71}7273// Clean up session74function cleanupSession(sessionId: string, reason: string) {75 const session = sessions.get(sessionId);76 if (!session) return;7778 console.log(`[${sessionId}] Cleaning up: ${reason}`);79 clearTimeout(session.expiryTimeout);80 clearInterval(session.heartbeat);8182 try { session.ptyProcess.kill(); } catch {}8384 if (session.ws.readyState === WebSocket.OPEN) {85 session.ws.send(JSON.stringify({ type: 'session_expired', reason }));86 session.ws.close(4001, reason);87 }8889 sessions.delete(sessionId);90}9192// HTTP Server for token endpoint93const httpServer = createServer((req, res) => {94 res.setHeader('Access-Control-Allow-Origin', '*');95 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');96 res.setHeader('Access-Control-Allow-Headers', 'Content-Type');9798 if (req.method === 'OPTIONS') {99 res.writeHead(204);100 res.end();101 return;102 }103104 const url = new URL(req.url || '/', `http://localhost:${PORT}`);105106 // Token generation endpoint107 if (url.pathname === '/api/token' && req.method === 'POST') {108 const { token, expiresAt, sessionId } = generateToken();109 console.log(`[${sessionId}] Token generated, expires: ${new Date(expiresAt).toISOString()}`);110111 res.writeHead(200, { 'Content-Type': 'application/json' });112 res.end(JSON.stringify({ token, expiresAt, sessionId }));113 return;114 }115116 res.writeHead(404);117 res.end(JSON.stringify({ error: 'Not found' }));118});119120// WebSocket Server with JWT validation121const wss = new WebSocketServer({ server: httpServer });122123wss.on('connection', (ws, req) => {124 const url = new URL(req.url || '/', `http://localhost:${PORT}`);125 const token = url.searchParams.get('token');126127 // Validate token128 if (!token) {129 ws.close(4000, 'No token provided');130 return;131 }132133 const payload = verifyToken(token);134 if (!payload) {135 ws.close(4001, 'Invalid or expired token');136 return;137 }138139 const { sessionId, expiresAt } = payload;140141 // Prevent duplicate sessions142 if (sessions.has(sessionId)) {143 ws.close(4002, 'Session already active');144 return;145 }146147 console.log(`[${sessionId}] Connected. Expires: ${new Date(expiresAt).toISOString()}`);148149 // Spawn PTY150 const ptyProcess = pty.spawn(shell, [], {151 name: 'xterm-256color',152 cols: 80,153 rows: 24,154 cwd: process.env.HOME || process.cwd(),155 env: process.env as Record<string, string>,156 });157158 // Set up auto-expiry159 const expiryTimeout = setTimeout(() => {160 cleanupSession(sessionId, 'Session expired');161 }, expiresAt - Date.now());162163 // Heartbeat164 let isAlive = true;165 ws.on('pong', () => { isAlive = true; });166 const heartbeat = setInterval(() => {167 if (!isAlive) {168 cleanupSession(sessionId, 'Client unresponsive');169 return;170 }171 isAlive = false;172 ws.ping();173 }, HEARTBEAT_INTERVAL);174175 // Store session176 sessions.set(sessionId, { sessionId, ws, ptyProcess, expiresAt, expiryTimeout, heartbeat });177178 // Send session info to client179 ws.send(JSON.stringify({ type: 'session_started', sessionId, expiresAt }));180181 // PTY data -> client182 ptyProcess.onData((data) => {183 if (ws.readyState === WebSocket.OPEN) {184 ws.send(JSON.stringify({ type: 'output', data }));185 }186 });187188 // Handle messages189 ws.on('message', (message) => {190 const msg = JSON.parse(message.toString());191 if (msg.type === 'input') ptyProcess.write(msg.data);192 if (msg.type === 'resize') ptyProcess.resize(msg.cols, msg.rows);193 });194195 ws.on('close', () => cleanupSession(sessionId, 'Client disconnected'));196 ptyProcess.onExit(() => cleanupSession(sessionId, 'Shell exited'));197});198199httpServer.listen(PORT, '0.0.0.0', () => {200 console.log(`Server running on http://localhost:${PORT}`);201 console.log(`Token endpoint: POST /api/token`);202 console.log(`Session duration: ${SESSION_DURATION_MS / 1000}s`);203});Frontend: Token Hook
Create src/hooks/useTerminalToken.ts:
1import { useState, useEffect, useCallback, useRef } from 'react';23interface UseTerminalTokenReturn {4 token: string | null;5 sessionId: string | null;6 expiresAt: Date | null;7 isLoading: boolean;8 error: string | null;9 isExpired: boolean;10 refreshToken: () => Promise<void>;11}1213export function useTerminalToken(apiUrl: string): UseTerminalTokenReturn {14 const [token, setToken] = useState<string | null>(null);15 const [sessionId, setSessionId] = useState<string | null>(null);16 const [expiresAt, setExpiresAt] = useState<Date | null>(null);17 const [isLoading, setIsLoading] = useState(true);18 const [error, setError] = useState<string | null>(null);19 const [isExpired, setIsExpired] = useState(false);2021 const expiryCheckRef = useRef<ReturnType<typeof setInterval> | null>(null);2223 const fetchToken = useCallback(async () => {24 try {25 setIsLoading(true);26 setError(null);27 setIsExpired(false);2829 const response = await fetch(`${apiUrl}/api/token`, { method: 'POST' });30 if (!response.ok) throw new Error('Failed to fetch token');3132 const data = await response.json();33 setToken(data.token);34 setSessionId(data.sessionId);35 setExpiresAt(new Date(data.expiresAt));3637 // Check for expiry every second38 if (expiryCheckRef.current) clearInterval(expiryCheckRef.current);39 expiryCheckRef.current = setInterval(() => {40 if (data.expiresAt < Date.now()) {41 setIsExpired(true);42 if (expiryCheckRef.current) clearInterval(expiryCheckRef.current);43 }44 }, 1000);45 } catch (err) {46 setError(err instanceof Error ? err.message : 'Failed to fetch token');47 } finally {48 setIsLoading(false);49 }50 }, [apiUrl]);5152 useEffect(() => {53 fetchToken();54 return () => {55 if (expiryCheckRef.current) clearInterval(expiryCheckRef.current);56 };57 }, [fetchToken]);5859 return { token, sessionId, expiresAt, isLoading, error, isExpired, refreshToken: fetchToken };60}Frontend: Expired Session Display
Create src/components/JwtExpiredDisplay.tsx:
1import type { TerminalTheme } from '../config/themes';23interface JwtExpiredDisplayProps {4 theme: TerminalTheme;5 onRequestNewSession: () => void;6 isLoading?: boolean;7}89export function JwtExpiredDisplay({ theme, onRequestNewSession, isLoading }: JwtExpiredDisplayProps) {10 return (11 <div12 style={{13 position: 'absolute',14 inset: 0,15 display: 'flex',16 flexDirection: 'column',17 alignItems: 'center',18 justifyContent: 'center',19 backgroundColor: theme.colors.background,20 color: theme.colors.foreground,21 fontFamily: 'monospace',22 textAlign: 'center',23 }}24 >25 <p style={{ fontSize: '20px', color: theme.colors.red, marginBottom: '8px' }}>26 > Session Expired27 </p>28 <p style={{ color: theme.colors.brightBlack, marginBottom: '24px' }}>29 > Your terminal session has ended.30 </p>31 <button32 onClick={onRequestNewSession}33 disabled={isLoading}34 style={{35 padding: '12px 24px',36 backgroundColor: theme.colors.green,37 color: theme.colors.background,38 border: 'none',39 borderRadius: '4px',40 cursor: isLoading ? 'not-allowed' : 'pointer',41 fontFamily: 'monospace',42 }}43 >44 {isLoading ? 'Requesting...' : 'Request New Session'}45 </button>46 </div>47 );48}Frontend: Updated App.tsx
Update src/App.tsx to use tokens:
1import { useState, useCallback, useMemo } from 'react';2import { Terminal } from './components/Terminal';3import { TerminalToolbar } from './components/TerminalToolbar';4import { JwtExpiredDisplay } from './components/JwtExpiredDisplay';5import { useTerminalTheme } from './hooks/useTerminalTheme';6import { useTerminalSettings } from './hooks/useTerminalSettings';7import { useTerminalToken } from './hooks/useTerminalToken';89const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';10const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:3001';1112function App() {13 const [isConnected, setIsConnected] = useState(false);14 const [sessionExpiredByServer, setSessionExpiredByServer] = useState(false);1516 const { theme, themes, setThemeById } = useTerminalTheme();17 const terminalSettings = useTerminalSettings();18 const { token, expiresAt, isLoading, error, isExpired, refreshToken } = useTerminalToken(API_URL);1920 // Build WebSocket URL with token21 const wsUrl = useMemo(() => {22 if (!token) return null;23 return `${WS_BASE_URL}?token=${encodeURIComponent(token)}`;24 }, [token]);2526 const handleServerMessage = useCallback((msg: { type: string }) => {27 if (msg.type === 'session_expired') setSessionExpiredByServer(true);28 }, []);2930 const handleRequestNewSession = useCallback(async () => {31 setSessionExpiredByServer(false);32 await refreshToken();33 }, [refreshToken]);3435 const showExpiredOverlay = isExpired || sessionExpiredByServer;3637 // Loading state38 if (isLoading && !token) {39 return <div>Requesting session token...</div>;40 }4142 return (43 <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>44 <TerminalToolbar45 isConnected={isConnected}46 sessionExpiresAt={expiresAt || undefined}47 /* ... other props ... */48 />4950 <div style={{ flex: 1, position: 'relative' }}>51 {wsUrl && (52 <Terminal53 wsUrl={wsUrl}54 theme={theme}55 settings={terminalSettings}56 onConnectionChange={setIsConnected}57 onServerMessage={handleServerMessage}58 />59 )}6061 {showExpiredOverlay && (62 <JwtExpiredDisplay63 theme={theme}64 onRequestNewSession={handleRequestNewSession}65 isLoading={isLoading}66 />67 )}68 </div>69 </div>70 );71}Testing JWT Sessions
Section 3: Docker Container Per Session
Now for the big one - each session gets its own isolated Docker container. When the session ends, the container is destroyed. This provides:
- Isolation: Users can't affect each other
- Security: No network access, resource limits
- Clean slate: Each session starts fresh
- Auto-cleanup: Containers removed when sessions expire
Architecture Overview
Session Container Image
Create server/Dockerfile.session - the lightweight container each user gets:
1# Lightweight terminal session container2FROM alpine:3.2134# Install common terminal tools5RUN apk add --no-cache \6 bash \7 coreutils \8 curl \9 git \10 htop \11 jq \12 nano \13 vim \14 wget1516# Create non-root user17RUN addgroup -g 1000 -S terminal && \18 adduser -S terminal -u 1000 -G terminal -h /home/terminal -s /bin/bash1920WORKDIR /home/terminal2122# Welcome message23RUN echo 'echo "Welcome to your terminal session!"' >> /home/terminal/.bashrc2425USER terminal26CMD ["sleep", "infinity"]Build the image:
Install Docker SDK
Updated docker-compose.yml
Mount the Docker socket so the backend can spawn containers:
1services:2 backend:3 build:4 context: ./server5 dockerfile: Dockerfile6 ports:7 - "3001:3001"8 environment:9 - JWT_SECRET=${JWT_SECRET:-dev-secret}10 - SESSION_DURATION_MS=${SESSION_DURATION_MS:-300000}11 - SESSION_IMAGE=terminal-session:latest12 - SESSION_MEMORY_LIMIT=${SESSION_MEMORY_LIMIT:-256m}13 - SESSION_CPU_LIMIT=${SESSION_CPU_LIMIT:-0.5}14 volumes:15 - /var/run/docker.sock:/var/run/docker.sock16 restart: unless-stoppedUpdated Server with Docker Management
The key changes to server/index.ts:
1import Docker from 'dockerode';23const docker = new Docker({ socketPath: '/var/run/docker.sock' });4const SESSION_IMAGE = process.env.SESSION_IMAGE || 'terminal-session:latest';5const SESSION_MEMORY_LIMIT = process.env.SESSION_MEMORY_LIMIT || '256m';6const SESSION_CPU_LIMIT = parseFloat(process.env.SESSION_CPU_LIMIT || '0.5');78// Create container for a session9async function createSessionContainer(sessionId: string): Promise<string> {10 const containerName = `term-${sessionId.slice(0, 8)}`;1112 const container = await docker.createContainer({13 Image: SESSION_IMAGE,14 name: containerName,15 Tty: true,16 OpenStdin: true,17 HostConfig: {18 Memory: parseMemoryLimit(SESSION_MEMORY_LIMIT),19 NanoCpus: Math.floor(SESSION_CPU_LIMIT * 1e9),20 AutoRemove: true, // Container removed when stopped21 NetworkMode: 'none', // No network access for security22 },23 Labels: {24 'terminal.session': sessionId,25 },26 });2728 await container.start();29 return container.id;30}3132// Attach to container shell33async function attachToContainer(containerId: string, ws: WebSocket) {34 const container = docker.getContainer(containerId);3536 const exec = await container.exec({37 AttachStdin: true,38 AttachStdout: true,39 AttachStderr: true,40 Tty: true,41 Cmd: ['/bin/bash'],42 });4344 const stream = await exec.start({ hijack: true, stdin: true, Tty: true });4546 // Pipe container output to WebSocket47 stream.on('data', (chunk: Buffer) => {48 ws.send(JSON.stringify({ type: 'output', data: chunk.toString() }));49 });5051 return stream;52}5354// Destroy container on session end55async function destroySessionContainer(containerId: string) {56 const container = docker.getContainer(containerId);57 await container.stop({ t: 2 }); // 2 second grace period58 // AutoRemove handles deletion59}Session Flow
- Client connects with JWT token
- Backend validates token and extracts session ID
- Container spawned:
term-abc12345with resource limits - Shell attached:
docker execinto container - I/O piped: Container stdout → WebSocket → Browser
- Session expires: Timer fires, container stopped and removed
Testing Container Isolation
Open two browser tabs and connect. Watch containers spawn:
You should see two separate containers. When sessions expire, they disappear.
Section 4: Multi-Terminal Tabs
Now let's add the ability to have multiple terminal sessions open at once, just like tabs in your favorite IDE. Each tab gets its own Docker container and JWT session.
Architecture
Session Management Hook
Create src/hooks/useTerminalSessions.ts to manage multiple sessions:
1import { useState, useCallback } from 'react';23export interface TerminalSession {4 id: string; // Local tab ID5 token: string; // JWT token6 sessionId: string; // Server session ID7 expiresAt: Date;8 name: string; // Display name9 isActive: boolean;10 isExpired: boolean;11 isConnected: boolean;12}1314interface UseTerminalSessionsReturn {15 sessions: TerminalSession[];16 activeSessionId: string | null;17 addSession: () => Promise<TerminalSession | null>;18 removeSession: (id: string) => void;19 setActiveSession: (id: string) => void;20 updateSession: (id: string, updates: Partial<TerminalSession>) => void;21 markSessionExpired: (id: string) => void;22 refreshSession: (id: string) => Promise<TerminalSession | null>;23}2425let sessionCounter = 0;2627export function useTerminalSessions(apiUrl: string): UseTerminalSessionsReturn {28 const [sessions, setSessions] = useState<TerminalSession[]>([]);29 const [activeSessionId, setActiveSessionId] = useState<string | null>(null);3031 const fetchToken = useCallback(async () => {32 const response = await fetch(`${apiUrl}/api/token`, { method: 'POST' });33 if (!response.ok) throw new Error('Failed to fetch token');34 return response.json();35 }, [apiUrl]);3637 const addSession = useCallback(async () => {38 const tokenData = await fetchToken();39 if (!tokenData) return null;4041 sessionCounter++;42 const newSession: TerminalSession = {43 id: `tab-${sessionCounter}`,44 token: tokenData.token,45 sessionId: tokenData.sessionId,46 expiresAt: new Date(tokenData.expiresAt),47 name: `Terminal ${sessionCounter}`,48 isActive: true,49 isExpired: false,50 isConnected: false,51 };5253 setSessions((prev) => {54 const updated = prev.map((s) => ({ ...s, isActive: false }));55 return [...updated, newSession];56 });5758 setActiveSessionId(newSession.id);59 return newSession;60 }, [fetchToken]);6162 const removeSession = useCallback((id: string) => {63 setSessions((prev) => {64 const filtered = prev.filter((s) => s.id !== id);6566 // Activate another tab if we closed the active one67 if (activeSessionId === id && filtered.length > 0) {68 const newActive = filtered[filtered.length - 1];69 newActive.isActive = true;70 setActiveSessionId(newActive.id);71 } else if (filtered.length === 0) {72 setActiveSessionId(null);73 }7475 return filtered;76 });77 }, [activeSessionId]);7879 const setActiveSession = useCallback((id: string) => {80 setSessions((prev) =>81 prev.map((s) => ({ ...s, isActive: s.id === id }))82 );83 setActiveSessionId(id);84 }, []);8586 const updateSession = useCallback((id: string, updates: Partial<TerminalSession>) => {87 setSessions((prev) =>88 prev.map((s) => (s.id === id ? { ...s, ...updates } : s))89 );90 }, []);9192 const markSessionExpired = useCallback((id: string) => {93 updateSession(id, { isExpired: true, isConnected: false });94 }, [updateSession]);9596 const refreshSession = useCallback(async (id: string) => {97 const tokenData = await fetchToken();98 if (!tokenData) return null;99100 setSessions((prev) =>101 prev.map((s) =>102 s.id === id103 ? {104 ...s,105 token: tokenData.token,106 sessionId: tokenData.sessionId,107 expiresAt: new Date(tokenData.expiresAt),108 isExpired: false,109 }110 : s111 )112 );113114 return sessions.find((s) => s.id === id) || null;115 }, [fetchToken, sessions]);116117 return {118 sessions,119 activeSessionId,120 addSession,121 removeSession,122 setActiveSession,123 updateSession,124 markSessionExpired,125 refreshSession,126 };127}Tab Bar Component
Create src/components/TabBar.tsx:
1import type { TerminalSession } from '../hooks/useTerminalSessions';23interface TabBarProps {4 sessions: TerminalSession[];5 activeSessionId: string | null;6 onSelectTab: (id: string) => void;7 onCloseTab: (id: string) => void;8 onAddTab: () => void;9 maxTabs?: number;10}1112export function TabBar({13 sessions,14 activeSessionId,15 onSelectTab,16 onCloseTab,17 onAddTab,18 maxTabs = 5,19}: TabBarProps) {20 const canAddMore = sessions.length < maxTabs;2122 return (23 <div24 style={{25 display: 'flex',26 alignItems: 'center',27 backgroundColor: '#0a0a0a',28 borderBottom: '1px solid rgba(255, 255, 255, 0.1)',29 padding: '0 8px',30 gap: '2px',31 minHeight: '36px',32 }}33 >34 {sessions.map((session) => (35 <div36 key={session.id}37 onClick={() => onSelectTab(session.id)}38 style={{39 display: 'flex',40 alignItems: 'center',41 gap: '8px',42 padding: '6px 12px',43 backgroundColor:44 session.id === activeSessionId45 ? 'rgba(255, 255, 255, 0.1)'46 : 'transparent',47 borderBottom:48 session.id === activeSessionId49 ? '2px solid #22c55e'50 : '2px solid transparent',51 cursor: 'pointer',52 }}53 >54 {/* Status dot */}55 <div56 style={{57 width: '6px',58 height: '6px',59 borderRadius: '50%',60 backgroundColor: session.isExpired61 ? '#ef4444' // Red: expired62 : session.isConnected63 ? '#22c55e' // Green: connected64 : '#eab308', // Yellow: connecting65 }}66 />6768 {/* Tab name */}69 <span70 style={{71 fontSize: '12px',72 fontFamily: 'monospace',73 color: session.isExpired ? '#6b7280' : '#e5e5e5',74 textDecoration: session.isExpired ? 'line-through' : 'none',75 }}76 >77 {session.name}78 </span>7980 {/* Close button */}81 <button82 onClick={(e) => {83 e.stopPropagation();84 onCloseTab(session.id);85 }}86 style={{87 width: '16px',88 height: '16px',89 padding: 0,90 backgroundColor: 'transparent',91 border: 'none',92 color: '#6b7280',93 cursor: 'pointer',94 }}95 >96 ×97 </button>98 </div>99 ))}100101 {/* Add tab button */}102 {canAddMore && (103 <button104 onClick={onAddTab}105 style={{106 width: '28px',107 height: '28px',108 backgroundColor: 'transparent',109 border: '1px solid rgba(255, 255, 255, 0.2)',110 borderRadius: '4px',111 color: '#6b7280',112 cursor: 'pointer',113 marginLeft: '4px',114 }}115 title="New terminal"116 >117 +118 </button>119 )}120121 {!canAddMore && (122 <span style={{ fontSize: '10px', color: '#6b7280', marginLeft: '8px' }}>123 Max {maxTabs} tabs124 </span>125 )}126 </div>127 );128}Updated App.tsx
Update src/App.tsx to support multiple tabs:
1import { useState, useCallback, useEffect } from 'react';2import { Terminal } from './components/Terminal';3import { TerminalToolbar } from './components/TerminalToolbar';4import { TabBar } from './components/TabBar';5import { JwtExpiredDisplay } from './components/JwtExpiredDisplay';6import { useTerminalTheme } from './hooks/useTerminalTheme';7import { useTerminalSettings } from './hooks/useTerminalSettings';8import { useTerminalSessions } from './hooks/useTerminalSessions';910const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';11const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:3001';1213function App() {14 const [isInitializing, setIsInitializing] = useState(true);1516 const { theme, themes, setThemeById } = useTerminalTheme();17 const terminalSettings = useTerminalSettings();1819 // Multi-session management20 const {21 sessions,22 activeSessionId,23 addSession,24 removeSession,25 setActiveSession,26 updateSession,27 markSessionExpired,28 refreshSession,29 } = useTerminalSessions(API_URL);3031 const activeSession = sessions.find((s) => s.id === activeSessionId);3233 // Create initial session on mount34 useEffect(() => {35 const init = async () => {36 await addSession();37 setIsInitializing(false);38 };39 init();40 }, []);4142 const handleConnectionChange = useCallback(43 (sessionId: string, connected: boolean) => {44 updateSession(sessionId, { isConnected: connected });45 },46 [updateSession]47 );4849 const handleServerMessage = useCallback(50 (sessionId: string, msg: { type: string }) => {51 if (msg.type === 'session_expired') {52 markSessionExpired(sessionId);53 }54 },55 [markSessionExpired]56 );5758 if (isInitializing) {59 return <div>Starting terminal...</div>;60 }6162 return (63 <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>64 {/* Tab Bar */}65 <TabBar66 sessions={sessions}67 activeSessionId={activeSessionId}68 onSelectTab={setActiveSession}69 onCloseTab={removeSession}70 onAddTab={addSession}71 maxTabs={5}72 />7374 {/* Toolbar */}75 <TerminalToolbar76 isConnected={activeSession?.isConnected || false}77 sessionExpiresAt={activeSession?.expiresAt}78 onSessionExpired={() => activeSession && markSessionExpired(activeSession.id)}79 themes={themes}80 currentTheme={theme}81 onThemeChange={setThemeById}82 settings={terminalSettings}83 onFontSizeChange={terminalSettings.setFontSize}84 onCursorBlinkChange={terminalSettings.setCursorBlink}85 onSettingsReset={terminalSettings.resetToDefaults}86 />8788 {/* Terminal Container - render all, show only active */}89 <div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>90 {sessions.map((session) => (91 <div92 key={session.id}93 style={{94 position: 'absolute',95 inset: 0,96 display: session.id === activeSessionId ? 'block' : 'none',97 }}98 >99 {!session.isExpired ? (100 <Terminal101 wsUrl={`${WS_BASE_URL}?token=${encodeURIComponent(session.token)}`}102 theme={theme}103 settings={terminalSettings}104 onConnectionChange={(connected) =>105 handleConnectionChange(session.id, connected)106 }107 onServerMessage={(msg) => handleServerMessage(session.id, msg)}108 />109 ) : (110 <JwtExpiredDisplay111 theme={theme}112 onRequestNewSession={() => refreshSession(session.id)}113 isLoading={false}114 />115 )}116 </div>117 ))}118 </div>119 </div>120 );121}122123export default App;Key Implementation Details
Why not unmount inactive tabs?
- State preservation: Terminal history, cursor position, and running commands are maintained
- No re-connection: WebSocket stays open, no need to reconnect when switching tabs
- Instant switching: No loading delay when switching between tabs
Tab lifecycle:
- Add tab: Fetch new JWT token → create session → spawn container
- Switch tab: Hide current terminal, show target (no network changes)
- Close tab: Disconnect WebSocket → container auto-destroyed
- Refresh expired: Fetch new token → replace session data → reconnect
Testing Multi-Tab
Watch containers as you add/remove tabs:
Section 5: Advanced Features
Let's add the finishing touches that make this a truly professional terminal experience.
Feature Overview
1. Auto-Refresh Tokens
Instead of waiting for tokens to expire, refresh them proactively 30 seconds before:
1// In useTerminalSessions.ts23// Auto-refresh buffer (refresh 30s before expiry)4const REFRESH_BUFFER_MS = 30000;56// Schedule auto-refresh for a session7const scheduleAutoRefresh = useCallback((sessionId: string, expiresAt: Date) => {8 // Clear existing timeout9 const existingTimeout = refreshTimeouts.current.get(sessionId);10 if (existingTimeout) {11 clearTimeout(existingTimeout);12 }1314 const timeUntilRefresh = expiresAt.getTime() - Date.now() - REFRESH_BUFFER_MS;1516 if (timeUntilRefresh > 0) {17 const timeout = setTimeout(async () => {18 console.log(`[${sessionId}] Auto-refreshing token (30s before expiry)`);1920 const tokenData = await fetchToken();21 if (tokenData) {22 setSessions((prev) =>23 prev.map((s) =>24 s.id === sessionId25 ? {26 ...s,27 token: tokenData.token,28 sessionId: tokenData.sessionId,29 expiresAt: new Date(tokenData.expiresAt),30 }31 : s32 )33 );3435 // Schedule next refresh36 scheduleAutoRefresh(sessionId, new Date(tokenData.expiresAt));37 }38 }, timeUntilRefresh);3940 refreshTimeouts.current.set(sessionId, timeout);41 }42}, [fetchToken]);2. Shell Type Selection
Let users choose their preferred shell per session. First, create a shared types file for type safety across the application.
Shared Types (src/types/index.ts):
1/**2 * Shared TypeScript types for the terminal application3 */45// Shell configuration6export type ShellType = 'bash' | 'sh' | 'zsh';78export interface ShellConfig {9 id: ShellType;10 name: string;11 command: string;12}1314// Process information from container15export interface ProcessInfo {16 pid: string;17 user: string;18 cpu: string;19 mem: string;20 command: string;21}2223// Terminal session state24export interface TerminalSession {25 id: string;26 token: string;27 sessionId: string;28 expiresAt: Date;29 name: string;30 isActive: boolean;31 isExpired: boolean;32 isConnected: boolean;33 shellType: ShellType;34 containerId?: string;35}3637// WebSocket message types38export interface WsMessage {39 type: string;40 data?: string;41 reason?: string;42 sessionId?: string;43 expiresAt?: number;44 containerId?: string;45 processes?: ProcessInfo[];46 shell?: ShellType;47}4849// Token response from API50export interface TokenResponse {51 token: string;52 sessionId: string;53 expiresAt: number;54}Shell Configuration (src/config/shells.ts):
1/**2 * Shell configuration for terminal sessions3 */45import type { ShellType } from '../types';67export interface ShellConfig {8 id: ShellType;9 name: string;10 command: string;11}1213export const SHELLS: ShellConfig[] = [14 {15 id: 'bash',16 name: 'Bash',17 command: '/bin/bash',18 },19 {20 id: 'sh',21 name: 'Shell',22 command: '/bin/sh',23 },24 {25 id: 'zsh',26 name: 'Zsh',27 command: '/bin/zsh',28 },29];3031export const DEFAULT_SHELL: ShellType = 'bash';3233export function getShellById(id: ShellType): ShellConfig {34 return SHELLS.find((s) => s.id === id) || SHELLS[0];35}3637export function getShellCommand(id: ShellType): string {38 return getShellById(id).command;39}ShellSelector Component (src/components/ShellSelector.tsx):
1/**2 * Shell Selector Component3 *4 * Allows users to switch between different shell types (bash, sh, zsh).5 * Uses react-icons for consistent iconography.6 */78import { useState, useRef, useEffect } from 'react';9import { VscTerminalBash, VscTerminal, VscSymbolMethod } from 'react-icons/vsc';10import { FiChevronDown, FiCheck } from 'react-icons/fi';11import type { TerminalTheme } from '../config/themes';12import type { ShellType } from '../types';13import { SHELLS } from '../config/shells';1415interface ShellSelectorProps {16 currentShell: ShellType;17 onShellChange: (shell: ShellType) => void;18 theme: TerminalTheme;19 disabled?: boolean;20}2122// Shell icon mapping23const SHELL_ICONS: Record<ShellType, typeof VscTerminalBash> = {24 bash: VscTerminalBash,25 sh: VscTerminal,26 zsh: VscSymbolMethod,27};2829export function ShellSelector({30 currentShell,31 onShellChange,32 theme,33 disabled = false,34}: ShellSelectorProps) {35 const [isOpen, setIsOpen] = useState(false);36 const dropdownRef = useRef<HTMLDivElement>(null);3738 // Close dropdown when clicking outside39 useEffect(() => {40 const handleClickOutside = (event: MouseEvent) => {41 if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {42 setIsOpen(false);43 }44 };45 document.addEventListener('mousedown', handleClickOutside);46 return () => document.removeEventListener('mousedown', handleClickOutside);47 }, []);4849 const currentShellConfig = SHELLS.find((s) => s.id === currentShell) || SHELLS[0];50 const CurrentIcon = SHELL_ICONS[currentShell];5152 return (53 <div ref={dropdownRef} style={{ position: 'relative' }}>54 <button55 onClick={() => !disabled && setIsOpen(!isOpen)}56 disabled={disabled}57 style={{58 display: 'flex',59 alignItems: 'center',60 gap: '6px',61 height: '32px',62 padding: '0 10px',63 backgroundColor: 'rgba(255, 255, 255, 0.1)',64 border: '1px solid rgba(255, 255, 255, 0.2)',65 borderRadius: '4px',66 color: disabled ? '#6b7280' : '#e5e5e5',67 fontSize: '12px',68 fontFamily: 'monospace',69 cursor: disabled ? 'not-allowed' : 'pointer',70 opacity: disabled ? 0.5 : 1,71 }}72 title={disabled ? 'Shell switching disabled while connected' : 'Select shell'}73 >74 <CurrentIcon size={14} />75 <span>{currentShellConfig.name}</span>76 <FiChevronDown size={12} />77 </button>7879 {isOpen && !disabled && (80 <div81 style={{82 position: 'absolute',83 top: 'calc(100% + 4px)',84 left: 0,85 minWidth: '140px',86 backgroundColor: theme.colors.background,87 border: '1px solid rgba(255, 255, 255, 0.2)',88 borderRadius: '4px',89 boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',90 zIndex: 100,91 }}92 >93 {SHELLS.map((shell) => {94 const isActive = shell.id === currentShell;95 const ShellIcon = SHELL_ICONS[shell.id];96 return (97 <button98 key={shell.id}99 onClick={() => {100 onShellChange(shell.id);101 setIsOpen(false);102 }}103 style={{104 display: 'flex',105 alignItems: 'center',106 gap: '10px',107 width: '100%',108 padding: '10px 12px',109 backgroundColor: isActive ? 'rgba(255, 255, 255, 0.1)' : 'transparent',110 border: 'none',111 borderLeft: isActive ? `2px solid ${theme.colors.green}` : '2px solid transparent',112 color: isActive ? theme.colors.foreground : '#9ca3af',113 fontSize: '12px',114 fontFamily: 'monospace',115 cursor: isActive ? 'default' : 'pointer',116 textAlign: 'left',117 }}118 >119 <ShellIcon size={16} />120 <span>{shell.name}</span>121 {isActive && <FiCheck size={14} color={theme.colors.green} style={{ marginLeft: 'auto' }} />}122 </button>123 );124 })}125 </div>126 )}127 </div>128 );129}Server Shell Handling:
1// server/index.ts - Accept shell type from WebSocket query23const SHELL_COMMANDS: Record<string, string> = {4 bash: '/bin/bash',5 sh: '/bin/sh',6 zsh: '/bin/zsh',7};89wss.on('connection', async (ws, req) => {10 const url = new URL(req.url || '/', `http://localhost:${PORT}`);11 const shellType = url.searchParams.get('shell') || 'bash';1213 // Use selected shell when attaching to container14 const shellCmd = SHELL_COMMANDS[shellType] || SHELL_COMMANDS.bash;1516 const exec = await container.exec({17 Cmd: [shellCmd],18 Env: [`SHELL=${shellCmd}`, 'TERM=xterm-256color'],19 // ...20 });21});Update Dockerfile.session to include zsh:
1RUN apk add --no-cache \2 bash \3 zsh \ # Add zsh4 procps \ # For ps command5 # ... other packages3. Tab Renaming
Double-click on a tab name to rename it:
1// In TabBar.tsx23const [editingTabId, setEditingTabId] = useState<string | null>(null);4const [editText, setEditText] = useState('');56const handleDoubleClick = (tabId: string, currentName: string) => {7 setEditingTabId(tabId);8 setEditText(currentName);9};1011const handleRenameCommit = () => {12 if (editingTabId && editText.trim()) {13 onRenameTab(editingTabId, editText.trim());14 }15 setEditingTabId(null);16 setEditText('');17};1819// In render:20{editingTabId === session.id ? (21 <input22 ref={inputRef}23 type="text"24 value={editText}25 onChange={(e) => setEditText(e.target.value)}26 onBlur={handleRenameCommit}27 onKeyDown={(e) => {28 if (e.key === 'Enter') handleRenameCommit();29 if (e.key === 'Escape') setEditingTabId(null);30 }}31 autoFocus32 />33) : (34 <span onDoubleClick={() => handleDoubleClick(session.id, session.name)}>35 {session.name}36 </span>37)}4. Tab Settings Panel with Process List
Add a settings panel that shows session info and running processes:
1// TabSettingsPanel.tsx23interface TabSettingsPanelProps {4 tabId: string;5 tabName: string;6 sessionId: string;7 shellType: ShellType;8 expiresAt: Date;9 processes: ProcessInfo[];10 isLoadingProcesses: boolean;11 onRequestProcessList: () => void;12}1314export function TabSettingsPanel({15 tabName,16 sessionId,17 shellType,18 expiresAt,19 processes,20 isLoadingProcesses,21 onRequestProcessList,22}: TabSettingsPanelProps) {23 return (24 <div className="settings-panel">25 <header>{tabName}</header>2627 <div className="info-grid">28 <div>29 <label>Session ID</label>30 <span>{sessionId.slice(0, 8)}...</span>31 </div>32 <div>33 <label>Shell</label>34 <span>{shellType}</span>35 </div>36 <div>37 <label>Time Remaining</label>38 <SessionCountdown expiresAt={expiresAt} />39 </div>40 </div>4142 <div className="process-list">43 <header>44 <span>Running Processes</span>45 <button onClick={onRequestProcessList}>46 {isLoadingProcesses ? 'Loading...' : 'Refresh'}47 </button>48 </header>49 <table>50 <thead>51 <tr><th>PID</th><th>CPU</th><th>MEM</th><th>CMD</th></tr>52 </thead>53 <tbody>54 {processes.map((proc) => (55 <tr key={proc.pid}>56 <td>{proc.pid}</td>57 <td>{proc.cpu}</td>58 <td>{proc.mem}</td>59 <td>{proc.command}</td>60 </tr>61 ))}62 </tbody>63 </table>64 </div>65 </div>66 );67}Server Process List Handler:
1// server/index.ts23async function getContainerProcesses(containerId: string): Promise<ProcessInfo[]> {4 const container = docker.getContainer(containerId);56 const exec = await container.exec({7 Cmd: ['ps', 'aux', '--no-headers'],8 AttachStdout: true,9 });1011 const stream = await exec.start({ hijack: true, stdin: false });1213 return new Promise((resolve) => {14 let output = '';15 stream.on('data', (chunk) => { output += chunk.toString(); });16 stream.on('end', () => {17 const processes = output.split('\n')18 .filter(Boolean)19 .map((line) => {20 const parts = line.trim().split(/\s+/);21 return {22 pid: parts[1],23 user: parts[0],24 cpu: parts[2] + '%',25 mem: parts[3] + '%',26 command: parts.slice(10).join(' '),27 };28 });29 resolve(processes);30 });31 });32}3334// In message handler:35case 'get_processes':36 const processes = await getContainerProcesses(session.containerId);37 ws.send(JSON.stringify({ type: 'processes', processes }));38 break;Testing Advanced Features
Section 6: Production Polish
The final touches for a truly production-ready terminal: quick actions, command history, and proper error handling.
1. Server Offline Display
Show a friendly error when the server is unreachable:
1// src/components/ServerOfflineDisplay.tsx23import { FiAlertTriangle, FiRefreshCw } from 'react-icons/fi';4import type { TerminalTheme } from '../config/themes';56interface ServerOfflineDisplayProps {7 theme: TerminalTheme;8 onRetry?: () => void;9 isRetrying?: boolean;10}1112export function ServerOfflineDisplay({13 theme,14 onRetry,15 isRetrying = false,16}: ServerOfflineDisplayProps) {17 return (18 <div19 style={{20 position: 'absolute',21 inset: 0,22 display: 'flex',23 flexDirection: 'column',24 alignItems: 'center',25 justifyContent: 'center',26 backgroundColor: theme.colors.background,27 color: theme.colors.foreground,28 fontFamily: 'monospace',29 textAlign: 'center',30 }}31 >32 <FiAlertTriangle size={64} color={theme.colors.red} />33 <p style={{ fontSize: '16px', fontWeight: 600, margin: '20px 0 12px' }}>34 > Terminal Server Offline35 </p>36 <p style={{ color: '#9ca3af', marginBottom: '24px' }}>37 > Unable to establish connection. Please try again.38 </p>39 {onRetry && (40 <button41 onClick={onRetry}42 disabled={isRetrying}43 style={{44 display: 'flex',45 alignItems: 'center',46 gap: '8px',47 padding: '12px 24px',48 backgroundColor: theme.colors.cyan,49 color: theme.colors.background,50 border: 'none',51 borderRadius: '6px',52 cursor: isRetrying ? 'not-allowed' : 'pointer',53 }}54 >55 <FiRefreshCw size={16} />56 {isRetrying ? 'Connecting...' : 'Retry Connection'}57 </button>58 )}59 </div>60 );61}Health check in App.tsx:
1const checkServerHealth = useCallback(async () => {2 try {3 const response = await fetch(`${API_URL}/health`);4 return response.ok;5 } catch {6 return false;7 }8}, []);910useEffect(() => {11 const init = async () => {12 const isHealthy = await checkServerHealth();13 if (!isHealthy) {14 setServerOffline(true);15 setIsInitializing(false);16 return;17 }18 await addSession();19 setIsInitializing(false);20 };21 init();22}, []);2. Quick Actions Panel
Predefined commands for common operations, organized by category with keyword search.
Quick Actions Configuration (src/config/quick-actions.ts):
1/**2 * Quick Actions Configuration3 *4 * Predefined commands for common terminal operations.5 * Organized by category for easy access.6 */78import type { IconType } from 'react-icons';9import {10 FiCpu,11 FiTerminal,12 FiGitBranch,13 FiFolder,14 FiFileText,15 FiGlobe,16 FiServer,17 FiActivity,18 FiHardDrive,19 FiUsers,20 FiClock,21 FiSearch,22} from 'react-icons/fi';23import { VscTerminalBash } from 'react-icons/vsc';2425export type ActionCategory =26 | 'System'27 | 'Shell'28 | 'Utilities'29 | 'Development'30 | 'Git'31 | 'Networking'32 | 'Monitoring';3334export interface QuickAction {35 id: string;36 name: string;37 command: string;38 icon: IconType;39 category: ActionCategory;40 description?: string;41 keywords?: string[];42}4344export const QUICK_ACTIONS: QuickAction[] = [45 // System46 {47 id: 'disk_usage',48 name: 'Disk Usage',49 command: 'df -h\n',50 icon: FiHardDrive,51 category: 'System',52 description: 'Display disk space usage',53 keywords: ['df', 'disk', 'storage', 'space'],54 },55 {56 id: 'memory_usage',57 name: 'Memory Usage',58 command: 'free -m\n',59 icon: FiCpu,60 category: 'System',61 description: 'Show memory usage in MB',62 keywords: ['free', 'memory', 'ram'],63 },64 {65 id: 'system_uptime',66 name: 'System Uptime',67 command: 'uptime\n',68 icon: FiClock,69 category: 'System',70 description: 'Show system uptime and load',71 keywords: ['uptime', 'load', 'running'],72 },7374 // Git75 {76 id: 'git_status',77 name: 'Git Status',78 command: 'git status 2>/dev/null || echo "Not a git repository"\n',79 icon: FiGitBranch,80 category: 'Git',81 description: 'Show git status',82 keywords: ['git', 'status'],83 },84 {85 id: 'git_branch',86 name: 'Git Branch',87 command: 'git branch -a 2>/dev/null || echo "Not a git repository"\n',88 icon: FiGitBranch,89 category: 'Git',90 description: 'List all branches',91 keywords: ['git', 'branch'],92 },9394 // Networking95 {96 id: 'check_ip',97 name: 'Public IP',98 command: 'curl -s ifconfig.me 2>/dev/null || echo "Unable to fetch IP"\n',99 icon: FiGlobe,100 category: 'Networking',101 description: 'Get public IP address',102 keywords: ['ip', 'public', 'address'],103 },104 {105 id: 'listening_ports',106 name: 'Listening Ports',107 command: 'ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || echo "ss/netstat not available"\n',108 icon: FiServer,109 category: 'Networking',110 description: 'Show listening TCP ports',111 keywords: ['ports', 'listening', 'tcp'],112 },113114 // Utilities115 {116 id: 'list_files',117 name: 'List Files',118 command: 'ls -lah\n',119 icon: FiFolder,120 category: 'Utilities',121 description: 'List files with details',122 keywords: ['ls', 'files', 'directory'],123 },124125 // Monitoring126 {127 id: 'top_processes',128 name: 'Top Processes',129 command: 'ps aux --sort=-%cpu | head -10\n',130 icon: FiActivity,131 category: 'Monitoring',132 description: 'Top 10 CPU processes',133 keywords: ['top', 'processes', 'cpu'],134 },135];136137export const ACTION_CATEGORIES: ActionCategory[] = [138 'System',139 'Shell',140 'Utilities',141 'Development',142 'Git',143 'Networking',144 'Monitoring',145];146147export function getActionsByCategory(category: ActionCategory): QuickAction[] {148 return QUICK_ACTIONS.filter((action) => action.category === category);149}150151export function searchActions(query: string): QuickAction[] {152 const lowerQuery = query.toLowerCase();153 return QUICK_ACTIONS.filter(154 (action) =>155 action.name.toLowerCase().includes(lowerQuery) ||156 action.command.toLowerCase().includes(lowerQuery) ||157 action.description?.toLowerCase().includes(lowerQuery) ||158 action.keywords?.some((kw) => kw.toLowerCase().includes(lowerQuery))159 );160}QuickActionsPanel Component (src/components/QuickActionsPanel.tsx):
1/**2 * Quick Actions Panel Component3 *4 * Provides quick access to common terminal commands.5 * Searchable and categorized for easy navigation.6 */78import { useState, useMemo } from 'react';9import { FiSearch, FiPlay, FiChevronDown, FiChevronRight } from 'react-icons/fi';10import type { TerminalTheme } from '../config/themes';11import {12 ACTION_CATEGORIES,13 searchActions,14 getActionsByCategory,15 type ActionCategory,16 type QuickAction,17} from '../config/quick-actions';1819interface QuickActionsPanelProps {20 theme: TerminalTheme;21 onExecuteCommand: (command: string) => void;22 onClose: () => void;23}2425export function QuickActionsPanel({26 theme,27 onExecuteCommand,28 onClose,29}: QuickActionsPanelProps) {30 const [searchQuery, setSearchQuery] = useState('');31 const [expandedCategories, setExpandedCategories] = useState<Set<ActionCategory>>(32 new Set(['System', 'Utilities'])33 );3435 // Filter actions based on search36 const filteredActions = useMemo(() => {37 if (searchQuery.trim()) {38 return searchActions(searchQuery);39 }40 return null; // Show categorized view41 }, [searchQuery]);4243 // Toggle category expansion44 const toggleCategory = (category: ActionCategory) => {45 setExpandedCategories((prev) => {46 const next = new Set(prev);47 if (next.has(category)) {48 next.delete(category);49 } else {50 next.add(category);51 }52 return next;53 });54 };5556 // Execute action57 const handleExecute = (action: QuickAction) => {58 onExecuteCommand(action.command);59 onClose();60 };6162 const renderAction = (action: QuickAction) => {63 const Icon = action.icon;64 return (65 <button66 key={action.id}67 onClick={() => handleExecute(action)}68 style={{69 display: 'flex',70 alignItems: 'center',71 gap: '10px',72 width: '100%',73 padding: '10px 12px',74 backgroundColor: 'transparent',75 border: 'none',76 borderRadius: '4px',77 color: theme.colors.foreground,78 fontSize: '12px',79 fontFamily: 'monospace',80 cursor: 'pointer',81 textAlign: 'left',82 }}83 title={action.description}84 >85 <Icon size={14} color={theme.colors.cyan} />86 <div style={{ flex: 1, minWidth: 0 }}>87 <div style={{ fontWeight: 500 }}>{action.name}</div>88 {action.description && (89 <div style={{ fontSize: '10px', color: '#9ca3af', marginTop: '2px' }}>90 {action.description}91 </div>92 )}93 </div>94 <FiPlay size={12} color="#6b7280" />95 </button>96 );97 };9899 return (100 <div101 style={{102 width: '320px',103 maxHeight: '400px',104 backgroundColor: theme.colors.background,105 border: '1px solid rgba(255, 255, 255, 0.15)',106 borderRadius: '8px',107 boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)',108 overflow: 'hidden',109 display: 'flex',110 flexDirection: 'column',111 }}112 >113 {/* Search */}114 <div style={{ padding: '12px', borderBottom: '1px solid rgba(255, 255, 255, 0.1)' }}>115 <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 12px', backgroundColor: 'rgba(255, 255, 255, 0.05)', borderRadius: '6px' }}>116 <FiSearch size={14} color="#6b7280" />117 <input118 type="text"119 value={searchQuery}120 onChange={(e) => setSearchQuery(e.target.value)}121 placeholder="Search commands..."122 style={{ flex: 1, backgroundColor: 'transparent', border: 'none', outline: 'none', color: theme.colors.foreground, fontSize: '12px', fontFamily: 'monospace' }}123 autoFocus124 />125 </div>126 </div>127128 {/* Actions */}129 <div style={{ flex: 1, overflowY: 'auto', padding: '8px' }}>130 {filteredActions ? (131 filteredActions.map(renderAction)132 ) : (133 ACTION_CATEGORIES.map((category) => {134 const actions = getActionsByCategory(category);135 if (actions.length === 0) return null;136 const isExpanded = expandedCategories.has(category);137138 return (139 <div key={category}>140 <button141 onClick={() => toggleCategory(category)}142 style={{ display: 'flex', alignItems: 'center', gap: '6px', width: '100%', padding: '8px 10px', backgroundColor: 'transparent', border: 'none', color: '#9ca3af', fontSize: '10px', fontFamily: 'monospace', fontWeight: 600, textTransform: 'uppercase', cursor: 'pointer' }}143 >144 {isExpanded ? <FiChevronDown size={12} /> : <FiChevronRight size={12} />}145 {category}146 <span style={{ marginLeft: 'auto', opacity: 0.6 }}>{actions.length}</span>147 </button>148 {isExpanded && actions.map(renderAction)}149 </div>150 );151 })152 )}153 </div>154155 <div style={{ padding: '8px 12px', borderTop: '1px solid rgba(255, 255, 255, 0.1)', fontSize: '10px', color: '#6b7280', textAlign: 'center' }}>156 Click a command to execute157 </div>158 </div>159 );160}3. Command History Panel
Track and export command history with JSON/CSV export:
Command History Panel (src/components/CommandHistoryPanel.tsx):
1/**2 * Command History Panel Component3 *4 * Displays and exports command history for the current session.5 * Supports JSON and CSV export formats.6 */78import { FiClock, FiTerminal, FiDownload, FiTrash2, FiGitBranch } from 'react-icons/fi';9import { VscTerminalBash } from 'react-icons/vsc';10import type { TerminalTheme } from '../config/themes';1112export interface CommandHistoryEntry {13 command: string;14 timestamp: Date;15}1617interface CommandHistoryPanelProps {18 history: CommandHistoryEntry[];19 theme: TerminalTheme;20 onClearHistory?: () => void;21 onClose: () => void;22}2324// Get icon based on command prefix25function getCommandIcon(command: string) {26 const lowerCommand = command.toLowerCase().trim();27 if (lowerCommand.startsWith('git ')) return FiGitBranch;28 if (lowerCommand.startsWith('bash') || lowerCommand.startsWith('sh ')) return VscTerminalBash;29 return FiTerminal;30}3132export function CommandHistoryPanel({33 history,34 theme,35 onClearHistory,36}: CommandHistoryPanelProps) {37 // Download file helper38 const downloadFile = (filename: string, content: string, mimeType: string) => {39 const element = document.createElement('a');40 element.setAttribute('href', `data:${mimeType};charset=utf-8,${encodeURIComponent(content)}`);41 element.setAttribute('download', filename);42 element.style.display = 'none';43 document.body.appendChild(element);44 element.click();45 document.body.removeChild(element);46 };4748 // Export as JSON49 const handleExportJSON = () => {50 const exportData = history.map((entry) => ({51 command: entry.command,52 timestamp: entry.timestamp.toISOString(),53 }));54 downloadFile('command_history.json', JSON.stringify(exportData, null, 2), 'application/json');55 };5657 // Export as CSV58 const handleExportCSV = () => {59 let csvContent = 'Timestamp,Command\n';60 history.forEach((entry) => {61 const timestamp = entry.timestamp.toISOString();62 const command = `"${entry.command.replace(/"/g, '""')}"`;63 csvContent += `${timestamp},${command}\n`;64 });65 downloadFile('command_history.csv', csvContent, 'text/csv');66 };6768 const buttonStyle: React.CSSProperties = {69 display: 'flex',70 alignItems: 'center',71 gap: '6px',72 height: '28px',73 padding: '0 10px',74 backgroundColor: 'rgba(255, 255, 255, 0.05)',75 border: '1px solid rgba(255, 255, 255, 0.1)',76 borderRadius: '4px',77 color: '#9ca3af',78 fontSize: '11px',79 fontFamily: 'monospace',80 cursor: 'pointer',81 };8283 return (84 <div85 style={{86 width: '360px',87 maxHeight: '400px',88 backgroundColor: theme.colors.background,89 border: '1px solid rgba(255, 255, 255, 0.15)',90 borderRadius: '8px',91 boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)',92 overflow: 'hidden',93 display: 'flex',94 flexDirection: 'column',95 }}96 >97 {/* Header */}98 <div99 style={{100 display: 'flex',101 alignItems: 'center',102 justifyContent: 'space-between',103 padding: '12px 14px',104 borderBottom: '1px solid rgba(255, 255, 255, 0.1)',105 backgroundColor: 'rgba(255, 255, 255, 0.03)',106 }}107 >108 <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>109 <FiClock size={14} color={theme.colors.cyan} />110 <span style={{ fontSize: '13px', fontFamily: 'monospace', fontWeight: 600, color: theme.colors.foreground }}>111 Command History112 </span>113 <span style={{ fontSize: '10px', fontFamily: 'monospace', color: '#6b7280', backgroundColor: 'rgba(255, 255, 255, 0.1)', padding: '2px 6px', borderRadius: '4px' }}>114 {history.length}115 </span>116 </div>117 </div>118119 {/* History List */}120 <div style={{ flex: 1, overflowY: 'auto', padding: '8px' }}>121 {history.length === 0 ? (122 <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '32px', color: '#6b7280' }}>123 <FiTerminal size={32} style={{ marginBottom: '12px', opacity: 0.5 }} />124 <p style={{ fontSize: '12px', fontFamily: 'monospace' }}>No commands recorded yet</p>125 </div>126 ) : (127 history.map((entry, index) => {128 const Icon = getCommandIcon(entry.command);129 return (130 <div key={`${entry.timestamp.toISOString()}-${index}`} style={{ display: 'flex', alignItems: 'flex-start', gap: '10px', padding: '10px 12px', borderRadius: '6px' }}>131 <Icon size={14} color={theme.colors.cyan} style={{ marginTop: '2px' }} />132 <div style={{ flex: 1 }}>133 <div style={{ fontSize: '12px', fontFamily: 'monospace', color: theme.colors.green, wordBreak: 'break-all' }}>134 {entry.command.trim()}135 </div>136 <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '4px', fontSize: '10px', color: '#6b7280' }}>137 <FiClock size={10} />138 {entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}139 </div>140 </div>141 </div>142 );143 })144 )}145 </div>146147 {/* Footer with export buttons */}148 {history.length > 0 && (149 <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 12px', borderTop: '1px solid rgba(255, 255, 255, 0.1)' }}>150 <div style={{ display: 'flex', gap: '6px' }}>151 <button onClick={handleExportJSON} style={buttonStyle}>152 <FiDownload size={12} /> JSON153 </button>154 <button onClick={handleExportCSV} style={buttonStyle}>155 <FiDownload size={12} /> CSV156 </button>157 </div>158 {onClearHistory && (159 <button onClick={onClearHistory} style={{ ...buttonStyle, borderColor: 'rgba(239, 68, 68, 0.3)' }}>160 <FiTrash2 size={12} /> Clear161 </button>162 )}163 </div>164 )}165 </div>166 );167}4. Integrating into the Toolbar
Add buttons to TerminalToolbar:
1// Updated TerminalToolbar.tsx23import { FiZap, FiClock } from 'react-icons/fi';4import { QuickActionsPanel } from './QuickActionsPanel';5import { CommandHistoryPanel, type CommandHistoryEntry } from './CommandHistoryPanel';67interface TerminalToolbarProps {8 // ... existing props9 onExecuteCommand?: (command: string) => void;10 commandHistory?: CommandHistoryEntry[];11 onClearHistory?: () => void;12}1314export function TerminalToolbar({ /* props */ }) {15 const [showQuickActions, setShowQuickActions] = useState(false);16 const [showHistory, setShowHistory] = useState(false);1718 return (19 <div style={{ /* toolbar styles */ }}>20 {/* Quick Actions Button */}21 <button onClick={() => setShowQuickActions(!showQuickActions)}>22 <FiZap size={14} />23 <span>Actions</span>24 </button>2526 {/* History Button */}27 <button onClick={() => setShowHistory(!showHistory)}>28 <FiClock size={14} />29 <span>History</span>30 {commandHistory.length > 0 && (31 <span className="badge">{commandHistory.length}</span>32 )}33 </button>3435 {/* Popover panels */}36 {showQuickActions && (37 <QuickActionsPanel38 theme={currentTheme}39 onExecuteCommand={onExecuteCommand}40 onClose={() => setShowQuickActions(false)}41 />42 )}4344 {showHistory && (45 <CommandHistoryPanel46 history={commandHistory}47 theme={currentTheme}48 onClearHistory={onClearHistory}49 onClose={() => setShowHistory(false)}50 />51 )}52 </div>53 );54}Testing Production Features
Section 7: Production Bugfixes
Real-world deployments reveal edge cases that testing doesn't catch. This section covers critical bugfixes for React StrictMode, WebSocket race conditions, and Docker container conflicts.
1. React StrictMode Double-Mount Guard
React StrictMode intentionally mounts components twice in development to help find bugs. Without protection, this creates duplicate containers:
1// src/App.tsx - Add guard for StrictMode double-initialization23import { useRef } from 'react';45function App() {6 // Guard against StrictMode double-initialization7 const hasInitializedRef = useRef(false);89 useEffect(() => {10 // Prevent double-initialization in React StrictMode11 if (hasInitializedRef.current) {12 console.log('[App] Skipping duplicate initialization (StrictMode)');13 return;14 }15 hasInitializedRef.current = true;1617 const init = async () => {18 console.log('[App] Initializing...');19 const isHealthy = await checkServerHealth();20 if (!isHealthy) {21 setServerOffline(true);22 setIsInitializing(false);23 return;24 }2526 const session = await addSession();27 if (!session) {28 setServerOffline(true);29 }30 setIsInitializing(false);31 console.log('[App] Initialization complete');32 };33 init();34 }, []);3536 // ... rest of component37}2. WebSocket Connection Guards
The WebSocket hook needs guards to prevent duplicate connections and handle URL changes correctly:
1// src/hooks/useWebSocket.ts - Full production version23import { useEffect, useRef, useState, useCallback } from 'react';45export interface WebSocketOptions {6 maxRetries?: number;7 baseDelay?: number;8 maxDelay?: number;9 onOpen?: (ws: WebSocket) => void;10 onMessage?: (event: MessageEvent) => void;11 onClose?: (event: CloseEvent) => void;12 onError?: (event: Event) => void;13 onReconnecting?: (attempt: number, maxAttempts: number) => void;14 onMaxRetriesReached?: () => void;15}1617export function useWebSocket(url: string, options: WebSocketOptions = {}) {18 const [isConnected, setIsConnected] = useState(false);19 const [isReconnecting, setIsReconnecting] = useState(false);20 const [reconnectAttempt, setReconnectAttempt] = useState(0);21 const [wsInstance, setWsInstance] = useState<WebSocket | null>(null);2223 const wsRef = useRef<WebSocket | null>(null);24 const reconnectAttemptsRef = useRef(0);25 const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);26 const shouldReconnectRef = useRef(true);27 const mountedRef = useRef(false);2829 // CRITICAL: Track if initial connection has been made30 // Prevents URL change effect from firing on mount31 const hasConnectedRef = useRef(false);3233 // CRITICAL: Track if we're currently connecting34 // Prevents duplicate connections from StrictMode35 const isConnectingRef = useRef(false);3637 // Store latest values in refs38 const urlRef = useRef(url);39 const optionsRef = useRef(options);40 const previousUrlRef = useRef(url);4142 useEffect(() => { urlRef.current = url; }, [url]);43 useEffect(() => { optionsRef.current = options; }, [options]);4445 const getReconnectDelay = useCallback((attempt: number) => {46 const { baseDelay = 1000, maxDelay = 30000 } = optionsRef.current;47 const exponentialDelay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);48 return exponentialDelay + exponentialDelay * 0.25 * Math.random();49 }, []);5051 const connectFnRef = useRef<() => void>(() => {});5253 const connect = useCallback(() => {54 if (!mountedRef.current) return;5556 // CRITICAL: Prevent duplicate connections57 if (isConnectingRef.current) {58 console.log('[useWebSocket] Already connecting, skipping');59 return;60 }61 isConnectingRef.current = true;6263 // Clean up existing connection64 if (wsRef.current) {65 wsRef.current.onopen = null;66 wsRef.current.onmessage = null;67 wsRef.current.onclose = null;68 wsRef.current.onerror = null;69 wsRef.current.close();70 wsRef.current = null;71 }7273 if (reconnectTimeoutRef.current) {74 clearTimeout(reconnectTimeoutRef.current);75 reconnectTimeoutRef.current = null;76 }7778 const ws = new WebSocket(urlRef.current);79 ws.binaryType = 'arraybuffer';80 wsRef.current = ws;81 setWsInstance(ws);8283 ws.onopen = () => {84 if (!mountedRef.current) return;85 isConnectingRef.current = false;86 hasConnectedRef.current = true;87 setIsConnected(true);88 setIsReconnecting(false);89 reconnectAttemptsRef.current = 0;90 setReconnectAttempt(0);91 optionsRef.current.onOpen?.(ws);92 };9394 ws.onmessage = (event) => {95 if (!mountedRef.current) return;96 optionsRef.current.onMessage?.(event);97 };9899 ws.onclose = (event) => {100 if (!mountedRef.current) return;101 isConnectingRef.current = false;102 setIsConnected(false);103 setWsInstance(null);104105 if (shouldReconnectRef.current) {106 optionsRef.current.onClose?.(event);107 }108109 // Don't auto-reconnect on auth failures110 const noReconnectCodes = [4000, 4001, 4002, 4003];111 if (noReconnectCodes.includes(event.code)) {112 console.log('[useWebSocket] Not reconnecting due to close code:', event.code);113 setIsReconnecting(false);114 return;115 }116117 const { maxRetries = 10 } = optionsRef.current;118 if (shouldReconnectRef.current && reconnectAttemptsRef.current < maxRetries) {119 const delay = getReconnectDelay(reconnectAttemptsRef.current);120 setIsReconnecting(true);121 reconnectAttemptsRef.current++;122 setReconnectAttempt(reconnectAttemptsRef.current);123 optionsRef.current.onReconnecting?.(reconnectAttemptsRef.current, maxRetries);124125 reconnectTimeoutRef.current = setTimeout(() => {126 if (mountedRef.current && shouldReconnectRef.current) {127 connectFnRef.current();128 }129 }, delay);130 } else if (reconnectAttemptsRef.current >= maxRetries) {131 setIsReconnecting(false);132 optionsRef.current.onMaxRetriesReached?.();133 }134 };135136 ws.onerror = (event) => {137 if (!mountedRef.current) return;138 isConnectingRef.current = false;139 optionsRef.current.onError?.(event);140 };141 }, [getReconnectDelay]);142143 connectFnRef.current = connect;144145 // Initial connection - runs once on mount146 useEffect(() => {147 mountedRef.current = true;148 shouldReconnectRef.current = true;149 hasConnectedRef.current = false;150 isConnectingRef.current = false;151152 connectFnRef.current();153154 return () => {155 mountedRef.current = false;156 shouldReconnectRef.current = false;157 isConnectingRef.current = false;158159 if (reconnectTimeoutRef.current) {160 clearTimeout(reconnectTimeoutRef.current);161 }162163 if (wsRef.current) {164 wsRef.current.onopen = null;165 wsRef.current.onmessage = null;166 wsRef.current.onclose = null;167 wsRef.current.onerror = null;168 wsRef.current.close();169 wsRef.current = null;170 }171 };172 }, []); // Empty deps - only run on mount173174 // Reconnect when URL changes (e.g., token refresh)175 // CRITICAL: Only triggers AFTER initial connection176 useEffect(() => {177 const urlChanged = previousUrlRef.current !== url;178 previousUrlRef.current = url;179180 if (urlChanged && hasConnectedRef.current && mountedRef.current) {181 console.log('[useWebSocket] URL changed, reconnecting with new token');182 reconnectAttemptsRef.current = 0;183 setReconnectAttempt(0);184 isConnectingRef.current = false;185 connectFnRef.current();186 }187 }, [url]);188189 // ... send, reconnect, disconnect methods190 return { ws: wsInstance, isConnected, isReconnecting, reconnectAttempt, send, reconnect, disconnect };191}3. Terminal Dispose Guards
xterm.js throws errors if you try to write to a disposed terminal. Add defensive wrappers:
1// src/components/Terminal.tsx - Safe write methods23export function Terminal({ wsUrl, theme, settings, ...props }: TerminalProps) {4 const terminalRef = useRef<HTMLDivElement>(null);5 const xtermRef = useRef<XTerm | null>(null);6 const fitAddonRef = useRef<FitAddon | null>(null);7 const [isTerminalReady, setIsTerminalReady] = useState(false);89 // CRITICAL: Track disposed state to prevent xterm crashes10 const isDisposedRef = useRef(false);1112 // Safe terminal write - checks if terminal exists and is not disposed13 const safeWrite = useCallback((text: string) => {14 const term = xtermRef.current;15 if (term && !isDisposedRef.current) {16 try {17 term.write(text);18 } catch (e) {19 console.warn('[Terminal] Write failed:', e);20 }21 }22 }, []);2324 const safeWriteln = useCallback((text: string) => {25 const term = xtermRef.current;26 if (term && !isDisposedRef.current) {27 try {28 term.writeln(text);29 } catch (e) {30 console.warn('[Terminal] Writeln failed:', e);31 }32 }33 }, []);3435 const safeClear = useCallback(() => {36 const term = xtermRef.current;37 if (term && !isDisposedRef.current) {38 try {39 term.clear();40 } catch (e) {41 console.warn('[Terminal] Clear failed:', e);42 }43 }44 }, []);4546 // Initialize terminal with delayed ready state47 useEffect(() => {48 if (!terminalRef.current) return;49 if (xtermRef.current) return; // Prevent re-initialization5051 isDisposedRef.current = false;5253 const term = new XTerm({54 cursorBlink: settings.cursorBlink,55 fontSize: settings.fontSize,56 fontFamily: settings.fontFamily,57 theme: theme.colors,58 allowProposedApi: true,59 });6061 const fitAddon = new FitAddon();62 const webLinksAddon = new WebLinksAddon();63 term.loadAddon(fitAddon);64 term.loadAddon(webLinksAddon);6566 term.open(terminalRef.current);67 xtermRef.current = term;68 fitAddonRef.current = fitAddon;6970 // CRITICAL: Delay fit and ready state to ensure terminal is fully rendered71 const readyTimeout = setTimeout(() => {72 if (!isDisposedRef.current && fitAddonRef.current) {73 try {74 fitAddonRef.current.fit();75 } catch (e) {76 console.warn('[Terminal] Initial fit failed:', e);77 }78 setIsTerminalReady(true);79 try {80 term.writeln('Connecting to terminal...');81 } catch (e) {82 console.warn('[Terminal] Initial write failed:', e);83 }84 }85 }, 100);8687 return () => {88 clearTimeout(readyTimeout);89 isDisposedRef.current = true;90 setIsTerminalReady(false);9192 try {93 term.dispose();94 } catch (e) {95 console.warn('[Terminal] Dispose failed:', e);96 }9798 xtermRef.current = null;99 fitAddonRef.current = null;100 };101 }, []); // Only initialize once102103 // Use safeWrite/safeWriteln in WebSocket callbacks104 const { ws, isConnected, send } = useWebSocket(wsUrl, {105 onOpen: () => {106 safeClear();107 safeWriteln('\x1b[32mConnected to terminal server\x1b[0m');108 },109 onMessage: (event) => {110 if (isDisposedRef.current) return;111 // ... handle messages with safeWrite112 },113 onClose: () => {114 safeWriteln('\x1b[31mConnection closed\x1b[0m');115 },116 });117118 // ... rest of component119}4. Backend Session Replacement
The backend should replace existing sessions instead of rejecting them, handling StrictMode reconnects gracefully:
1// server/index.ts - Replace session instead of rejecting23wss.on('connection', async (ws: WebSocket, req: IncomingMessage) => {4 // ... token validation ...56 const { sessionId, expiresAt } = payload;78 // CRITICAL: Replace existing session instead of rejecting9 // Handles React StrictMode double-mount and page refreshes10 if (sessions.has(sessionId)) {11 console.log(`[${sessionId}] Replacing existing session (StrictMode or reconnect)`);12 const existingSession = sessions.get(sessionId)!;1314 // Clear timeouts15 clearTimeout(existingSession.expiryTimeout);16 clearInterval(existingSession.heartbeat);1718 // Close old exec stream19 if (existingSession.execStream) {20 try { existingSession.execStream.end(); } catch {}21 }2223 // Close old WebSocket without triggering cleanup24 if (existingSession.ws.readyState === WebSocket.OPEN) {25 existingSession.ws.close(4002, 'Replaced by new connection');26 }2728 // Destroy old container (async, don't wait)29 destroySessionContainer(sessionId, existingSession.containerId).catch(() => {});3031 // Remove from sessions map32 sessions.delete(sessionId);33 }3435 // Continue with new session creation...36});5. Unique Container Names
Avoid Docker container name conflicts during rapid reconnection:
1// server/index.ts - Add timestamp suffix to container names23async function createSessionContainer(sessionId: string): Promise<string> {4 // Use timestamp suffix to avoid name conflicts during StrictMode double-mount5 const containerName = `term-${sessionId.slice(0, 8)}-${Date.now().toString(36)}`;67 console.log(`[${sessionId}] Creating container: ${containerName}`);89 const container = await docker.createContainer({10 Image: SESSION_IMAGE,11 name: containerName,12 // ... rest of config13 });1415 await container.start();16 return container.id;17}6. Environment Configuration
Ensure session duration is longer than the refresh buffer:
Testing Bugfixes
Summary
You now have a production-ready browser terminal with:
UI & Theming
Theme system, settings, status indicators
JWT Session Tokens
Token auth with auto-expiry
Docker Per Session
Isolated containers per user
Multi-Terminal Tabs
Tab interface for multiple sessions
Advanced Features
Auto-refresh, shells, rename, process list
Production Polish
Quick actions, history, offline handling
Production Bugfixes
StrictMode guards, race conditions, container conflicts
Full Source Code
The complete source code for this tutorial is available on GitHub:
View Full Source
Need Production Terminal Infrastructure?
Topics
Comments
Sign in to join the conversation
LoginNo comments yet. Be the first to share your thoughts!
Found an issue with this article?
