Skip to main content
/ Tutorial

Production Browser Terminal: UI Polish, Themes & Session Management

Sacha Roussakis-NotterSacha Roussakis-Notter
60 min read
React
TypeScript
Node.js
Source
Share

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.

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:

Themes
6
Font Sizes
10-24px
Status
Live
Session
JWT Auth
4 metricsbuun.group

Dependencies

Before we start, install the required packages:

terminal
$npm install react-icons jsonwebtoken uuid dockerode
$npm install -D @types/jsonwebtoken @types/uuid
2 commandsbuun.group

Here's what we're building - a polished terminal with theme switching and settings:

Production Terminal
wss://terminal.example.com/ws12ms
Theme: Dracula | Font: 14px | Session: 29:45
developer@cloud-dev:~/terminal-app$ ls -la src/config/
themes.ts
developer@cloud-dev:~/terminal-app$ ls -la src/hooks/
useTerminalTheme.ts useTerminalSettings.ts useWebSocket.ts
developer@cloud-dev:~/terminal-app$
● Connecteddeveloper@cloud-dev
buun.group

Project Structure

After this section, your project will look like this:

Project Structure
terminal-app/
src/
config/
themes.ts// NEW: Theme definitions
hooks/
useWebSocket.ts
useTerminalTheme.ts// NEW: Theme hook
useTerminalSettings.ts// NEW: Settings hook
components/
Terminal.tsx
TerminalToolbar.tsx// NEW: Toolbar component
StatusIndicator.tsx// NEW: Connection status
SessionTimer.tsx// NEW: Session countdown
ThemeSelector.tsx// NEW: Theme picker
SettingsPanel.tsx// NEW: Settings panel
App.tsx
server/
index.ts
Dockerfile
8 items at rootbuun.group

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:

typescript
1/**
2 * Terminal Theme Definitions
3 *
4 * Each theme provides colors for xterm.js terminal emulator.
5 * Themes are stored in localStorage for persistence.
6 */
7
8export 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}
35
36export 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];
200
201export const DEFAULT_THEME_ID = 'tokyo-night';
202
203export 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:

typescript
1import { useState, useEffect, useCallback } from 'react';
2import { THEMES, DEFAULT_THEME_ID, getThemeById, type TerminalTheme } from '../config/themes';
3
4const STORAGE_KEY = 'terminal-theme';
5
6interface UseTerminalThemeReturn {
7 theme: TerminalTheme;
8 themes: TerminalTheme[];
9 setThemeById: (id: string) => void;
10}
11
12export 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 });
22
23 // Persist to localStorage
24 useEffect(() => {
25 localStorage.setItem(STORAGE_KEY, theme.id);
26 }, [theme]);
27
28 const setThemeById = useCallback((id: string) => {
29 setTheme(getThemeById(id));
30 }, []);
31
32 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:

typescript
1import { useState, useEffect, useCallback } from 'react';
2
3const STORAGE_KEY = 'terminal-settings';
4
5export interface TerminalSettings {
6 fontSize: number;
7 fontFamily: string;
8 cursorBlink: boolean;
9}
10
11const DEFAULT_SETTINGS: TerminalSettings = {
12 fontSize: 14,
13 fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
14 cursorBlink: true,
15};
16
17interface UseTerminalSettingsReturn extends TerminalSettings {
18 setFontSize: (size: number) => void;
19 setFontFamily: (family: string) => void;
20 setCursorBlink: (blink: boolean) => void;
21 resetToDefaults: () => void;
22}
23
24export 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 defaults
35 }
36 }
37 return DEFAULT_SETTINGS;
38 });
39
40 // Persist to localStorage
41 useEffect(() => {
42 localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
43 }, [settings]);
44
45 const setFontSize = useCallback((size: number) => {
46 // Clamp between 10 and 24
47 const clamped = Math.max(10, Math.min(24, size));
48 setSettings((s) => ({ ...s, fontSize: clamped }));
49 }, []);
50
51 const setFontFamily = useCallback((family: string) => {
52 setSettings((s) => ({ ...s, fontFamily: family }));
53 }, []);
54
55 const setCursorBlink = useCallback((blink: boolean) => {
56 setSettings((s) => ({ ...s, cursorBlink: blink }));
57 }, []);
58
59 const resetToDefaults = useCallback(() => {
60 setSettings(DEFAULT_SETTINGS);
61 }, []);
62
63 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:

typescript
1interface StatusIndicatorProps {
2 isConnected: boolean;
3 isReconnecting: boolean;
4 reconnectAttempt?: number;
5}
6
7export 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 };
25
26 const { color, label, pulse } = getStatus();
27
28 return (
29 <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
30 <div
31 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:

typescript
1import { useState, useEffect } from 'react';
2
3interface SessionTimerProps {
4 expiresAt?: Date;
5 onExpired?: () => void;
6}
7
8export function SessionTimer({ expiresAt, onExpired }: SessionTimerProps) {
9 const [timeLeft, setTimeLeft] = useState<string>('');
10 const [isExpired, setIsExpired] = useState(false);
11 const [isWarning, setIsWarning] = useState(false);
12
13 useEffect(() => {
14 if (!expiresAt) return;
15
16 const updateTimer = () => {
17 const now = new Date();
18 const diff = expiresAt.getTime() - now.getTime();
19
20 if (diff <= 0) {
21 setTimeLeft('Expired');
22 setIsExpired(true);
23 onExpired?.();
24 return;
25 }
26
27 // Warning when less than 2 minutes left
28 setIsWarning(diff < 2 * 60 * 1000);
29
30 const minutes = Math.floor(diff / 60000);
31 const seconds = Math.floor((diff % 60000) / 1000);
32 setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`);
33 };
34
35 updateTimer();
36 const interval = setInterval(updateTimer, 1000);
37 return () => clearInterval(interval);
38 }, [expiresAt, onExpired]);
39
40 if (!expiresAt) return null;
41
42 const getColor = () => {
43 if (isExpired) return '#ef4444';
44 if (isWarning) return '#eab308';
45 return '#9ca3af';
46 };
47
48 return (
49 <div
50 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:

typescript
1import { useState, useRef, useEffect } from 'react';
2import type { TerminalTheme } from '../config/themes';
3
4interface ThemeSelectorProps {
5 themes: TerminalTheme[];
6 currentTheme: TerminalTheme;
7 onThemeChange: (themeId: string) => void;
8}
9
10export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSelectorProps) {
11 const [isOpen, setIsOpen] = useState(false);
12 const dropdownRef = useRef<HTMLDivElement>(null);
13
14 // Close dropdown when clicking outside
15 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 }, []);
24
25 return (
26 <div ref={dropdownRef} style={{ position: 'relative' }}>
27 <button
28 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 <div
44 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>
54
55 {isOpen && (
56 <div
57 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 <button
71 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 <div
92 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:

typescript
1import { useState, useRef, useEffect } from 'react';
2import type { TerminalSettings } from '../hooks/useTerminalSettings';
3
4interface SettingsPanelProps {
5 settings: TerminalSettings;
6 onFontSizeChange: (size: number) => void;
7 onCursorBlinkChange: (blink: boolean) => void;
8 onReset: () => void;
9}
10
11export function SettingsPanel({
12 settings,
13 onFontSizeChange,
14 onCursorBlinkChange,
15 onReset,
16}: SettingsPanelProps) {
17 const [isOpen, setIsOpen] = useState(false);
18 const panelRef = useRef<HTMLDivElement>(null);
19
20 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 }, []);
29
30 return (
31 <div ref={panelRef} style={{ position: 'relative' }}>
32 <button
33 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>
54
55 {isOpen && (
56 <div
57 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 SIZE
73 </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>
80
81 {/* Cursor Blink */}
82 <div style={{ marginBottom: '16px' }}>
83 <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
84 <input
85 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>
92
93 {/* Reset */}
94 <button onClick={onReset} style={{ width: '100%', padding: '8px', fontSize: '11px' }}>
95 Reset to Defaults
96 </button>
97 </div>
98 )}
99 </div>
100 );
101}

Step 8: Terminal Toolbar

Create src/components/TerminalToolbar.tsx to combine all controls:

typescript
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';
7
8interface 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}
22
23export function TerminalToolbar(props: TerminalToolbarProps) {
24 return (
25 <div
26 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 <StatusIndicator
38 isConnected={props.isConnected}
39 isReconnecting={props.isReconnecting}
40 reconnectAttempt={props.reconnectAttempt}
41 />
42 <SessionTimer expiresAt={props.sessionExpiresAt} onExpired={props.onSessionExpired} />
43 </div>
44
45 {/* Right: Theme + Settings */}
46 <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
47 <ThemeSelector
48 themes={props.themes}
49 currentTheme={props.currentTheme}
50 onThemeChange={props.onThemeChange}
51 />
52 <SettingsPanel
53 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:

typescript
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';
9
10interface TerminalProps {
11 wsUrl: string;
12 theme: TerminalTheme;
13 settings: TerminalSettings;
14 onConnectionChange?: (connected: boolean) => void;
15 onReconnecting?: (attempt: number, maxAttempts: number) => void;
16}
17
18export function Terminal({ wsUrl, theme, settings, onConnectionChange, onReconnecting }: TerminalProps) {
19 // ... existing terminal initialization code ...
20
21 // Update theme when it changes
22 useEffect(() => {
23 const term = xtermRef.current;
24 if (term) {
25 term.options.theme = theme.colors;
26 }
27 }, [theme]);
28
29 // Update settings when they change
30 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;
37
38 // Refit after font size change
39 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]);
48
49 // ... rest of component ...
50}

Part 5: Wire It All Together

Finally, update src/App.tsx:

typescript
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';
6
7const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3001';
8
9function App() {
10 const [isConnected, setIsConnected] = useState(false);
11 const [isReconnecting, setIsReconnecting] = useState(false);
12 const [reconnectAttempt, setReconnectAttempt] = useState(0);
13
14 const { theme, themes, setThemeById } = useTerminalTheme();
15 const terminalSettings = useTerminalSettings();
16
17 // Demo session expiry (30 minutes from now)
18 const [sessionExpiresAt] = useState<Date | undefined>(
19 () => new Date(Date.now() + 30 * 60 * 1000)
20 );
21
22 const handleConnectionChange = useCallback((connected: boolean) => {
23 setIsConnected(connected);
24 if (connected) {
25 setIsReconnecting(false);
26 setReconnectAttempt(0);
27 }
28 }, []);
29
30 const handleReconnecting = useCallback((attempt: number) => {
31 setIsReconnecting(true);
32 setReconnectAttempt(attempt);
33 }, []);
34
35 return (
36 <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', backgroundColor: '#0a0a0a' }}>
37 <TerminalToolbar
38 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 <Terminal
52 wsUrl={wsUrl}
53 theme={theme}
54 settings={terminalSettings}
55 onConnectionChange={handleConnectionChange}
56 onReconnecting={handleReconnecting}
57 />
58 </div>
59 </div>
60 );
61}
62
63export default App;

Testing Your Changes

Start the backend and frontend:

terminal
$Terminal 1: Backend
docker compose up -d --build
$Terminal 2: Frontend
npm run dev
2 commandsbuun.group

Open http://localhost:5173 and test:

Testing Checklist
0/7
0% completebuun.group

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:

terminal
$cd server
$npm install jsonwebtoken uuid
$npm install -D @types/jsonwebtoken @types/uuid
3 commandsbuun.group

Update your docker-compose.yml to pass environment variables:

yaml
1services:
2 backend:
3 build:
4 context: ./server
5 dockerfile: Dockerfile
6 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: true
12 stdin_open: true
13 restart: unless-stopped

Add to your .env file:

bash
1# Backend Configuration
2JWT_SECRET=your-super-secret-key-change-this
3SESSION_DURATION_MS=300000 # 5 minutes (use 30000 for 30s testing)

Backend: Updated Server with JWT

Replace server/index.ts with JWT authentication:

typescript
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';
9
10// Configuration from environment
11const 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;
15
16// Token payload interface
17interface TokenPayload {
18 sessionId: string;
19 expiresAt: number;
20 iat: number;
21}
22
23// Session tracking
24interface Session {
25 sessionId: string;
26 ws: WebSocket;
27 ptyProcess: pty.IPty;
28 expiresAt: number;
29 expiryTimeout: NodeJS.Timeout;
30 heartbeat: NodeJS.Timeout;
31}
32
33const sessions = new Map<string, Session>();
34
35// 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();
47
48// Generate JWT token
49function generateToken(): { token: string; expiresAt: number; sessionId: string } {
50 const sessionId = uuidv4();
51 const expiresAt = Date.now() + SESSION_DURATION_MS;
52
53 const token = jwt.sign(
54 { sessionId, expiresAt, iat: Date.now() },
55 JWT_SECRET,
56 { expiresIn: Math.floor(SESSION_DURATION_MS / 1000) }
57 );
58
59 return { token, expiresAt, sessionId };
60}
61
62// Verify JWT token
63function 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}
72
73// Clean up session
74function cleanupSession(sessionId: string, reason: string) {
75 const session = sessions.get(sessionId);
76 if (!session) return;
77
78 console.log(`[${sessionId}] Cleaning up: ${reason}`);
79 clearTimeout(session.expiryTimeout);
80 clearInterval(session.heartbeat);
81
82 try { session.ptyProcess.kill(); } catch {}
83
84 if (session.ws.readyState === WebSocket.OPEN) {
85 session.ws.send(JSON.stringify({ type: 'session_expired', reason }));
86 session.ws.close(4001, reason);
87 }
88
89 sessions.delete(sessionId);
90}
91
92// HTTP Server for token endpoint
93const 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');
97
98 if (req.method === 'OPTIONS') {
99 res.writeHead(204);
100 res.end();
101 return;
102 }
103
104 const url = new URL(req.url || '/', `http://localhost:${PORT}`);
105
106 // Token generation endpoint
107 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()}`);
110
111 res.writeHead(200, { 'Content-Type': 'application/json' });
112 res.end(JSON.stringify({ token, expiresAt, sessionId }));
113 return;
114 }
115
116 res.writeHead(404);
117 res.end(JSON.stringify({ error: 'Not found' }));
118});
119
120// WebSocket Server with JWT validation
121const wss = new WebSocketServer({ server: httpServer });
122
123wss.on('connection', (ws, req) => {
124 const url = new URL(req.url || '/', `http://localhost:${PORT}`);
125 const token = url.searchParams.get('token');
126
127 // Validate token
128 if (!token) {
129 ws.close(4000, 'No token provided');
130 return;
131 }
132
133 const payload = verifyToken(token);
134 if (!payload) {
135 ws.close(4001, 'Invalid or expired token');
136 return;
137 }
138
139 const { sessionId, expiresAt } = payload;
140
141 // Prevent duplicate sessions
142 if (sessions.has(sessionId)) {
143 ws.close(4002, 'Session already active');
144 return;
145 }
146
147 console.log(`[${sessionId}] Connected. Expires: ${new Date(expiresAt).toISOString()}`);
148
149 // Spawn PTY
150 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 });
157
158 // Set up auto-expiry
159 const expiryTimeout = setTimeout(() => {
160 cleanupSession(sessionId, 'Session expired');
161 }, expiresAt - Date.now());
162
163 // Heartbeat
164 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);
174
175 // Store session
176 sessions.set(sessionId, { sessionId, ws, ptyProcess, expiresAt, expiryTimeout, heartbeat });
177
178 // Send session info to client
179 ws.send(JSON.stringify({ type: 'session_started', sessionId, expiresAt }));
180
181 // PTY data -> client
182 ptyProcess.onData((data) => {
183 if (ws.readyState === WebSocket.OPEN) {
184 ws.send(JSON.stringify({ type: 'output', data }));
185 }
186 });
187
188 // Handle messages
189 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 });
194
195 ws.on('close', () => cleanupSession(sessionId, 'Client disconnected'));
196 ptyProcess.onExit(() => cleanupSession(sessionId, 'Shell exited'));
197});
198
199httpServer.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:

typescript
1import { useState, useEffect, useCallback, useRef } from 'react';
2
3interface 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}
12
13export 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);
20
21 const expiryCheckRef = useRef<ReturnType<typeof setInterval> | null>(null);
22
23 const fetchToken = useCallback(async () => {
24 try {
25 setIsLoading(true);
26 setError(null);
27 setIsExpired(false);
28
29 const response = await fetch(`${apiUrl}/api/token`, { method: 'POST' });
30 if (!response.ok) throw new Error('Failed to fetch token');
31
32 const data = await response.json();
33 setToken(data.token);
34 setSessionId(data.sessionId);
35 setExpiresAt(new Date(data.expiresAt));
36
37 // Check for expiry every second
38 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]);
51
52 useEffect(() => {
53 fetchToken();
54 return () => {
55 if (expiryCheckRef.current) clearInterval(expiryCheckRef.current);
56 };
57 }, [fetchToken]);
58
59 return { token, sessionId, expiresAt, isLoading, error, isExpired, refreshToken: fetchToken };
60}

Frontend: Expired Session Display

Create src/components/JwtExpiredDisplay.tsx:

typescript
1import type { TerminalTheme } from '../config/themes';
2
3interface JwtExpiredDisplayProps {
4 theme: TerminalTheme;
5 onRequestNewSession: () => void;
6 isLoading?: boolean;
7}
8
9export function JwtExpiredDisplay({ theme, onRequestNewSession, isLoading }: JwtExpiredDisplayProps) {
10 return (
11 <div
12 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 &gt; Session Expired
27 </p>
28 <p style={{ color: theme.colors.brightBlack, marginBottom: '24px' }}>
29 &gt; Your terminal session has ended.
30 </p>
31 <button
32 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:

typescript
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';
8
9const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
10const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:3001';
11
12function App() {
13 const [isConnected, setIsConnected] = useState(false);
14 const [sessionExpiredByServer, setSessionExpiredByServer] = useState(false);
15
16 const { theme, themes, setThemeById } = useTerminalTheme();
17 const terminalSettings = useTerminalSettings();
18 const { token, expiresAt, isLoading, error, isExpired, refreshToken } = useTerminalToken(API_URL);
19
20 // Build WebSocket URL with token
21 const wsUrl = useMemo(() => {
22 if (!token) return null;
23 return `${WS_BASE_URL}?token=${encodeURIComponent(token)}`;
24 }, [token]);
25
26 const handleServerMessage = useCallback((msg: { type: string }) => {
27 if (msg.type === 'session_expired') setSessionExpiredByServer(true);
28 }, []);
29
30 const handleRequestNewSession = useCallback(async () => {
31 setSessionExpiredByServer(false);
32 await refreshToken();
33 }, [refreshToken]);
34
35 const showExpiredOverlay = isExpired || sessionExpiredByServer;
36
37 // Loading state
38 if (isLoading && !token) {
39 return <div>Requesting session token...</div>;
40 }
41
42 return (
43 <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
44 <TerminalToolbar
45 isConnected={isConnected}
46 sessionExpiresAt={expiresAt || undefined}
47 /* ... other props ... */
48 />
49
50 <div style={{ flex: 1, position: 'relative' }}>
51 {wsUrl && (
52 <Terminal
53 wsUrl={wsUrl}
54 theme={theme}
55 settings={terminalSettings}
56 onConnectionChange={setIsConnected}
57 onServerMessage={handleServerMessage}
58 />
59 )}
60
61 {showExpiredOverlay && (
62 <JwtExpiredDisplay
63 theme={theme}
64 onRequestNewSession={handleRequestNewSession}
65 isLoading={isLoading}
66 />
67 )}
68 </div>
69 </div>
70 );
71}

Testing JWT Sessions

JWT Testing Checklist
0/7
0% completebuun.group

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

flowchart

Session Containers

Orchestrator Container

Browser

WebSocket + JWT

spawn

spawn

spawn

destroy on expiry

React Frontend

WebSocket Server

Docker Manager

term-abc123

term-def456

term-ghi789

Ctrl+scroll to zoom • Drag to pan45%

Session Container Image

Create server/Dockerfile.session - the lightweight container each user gets:

dockerfile
1# Lightweight terminal session container
2FROM alpine:3.21
3
4# Install common terminal tools
5RUN apk add --no-cache \
6 bash \
7 coreutils \
8 curl \
9 git \
10 htop \
11 jq \
12 nano \
13 vim \
14 wget
15
16# Create non-root user
17RUN addgroup -g 1000 -S terminal && \
18 adduser -S terminal -u 1000 -G terminal -h /home/terminal -s /bin/bash
19
20WORKDIR /home/terminal
21
22# Welcome message
23RUN echo 'echo "Welcome to your terminal session!"' >> /home/terminal/.bashrc
24
25USER terminal
26CMD ["sleep", "infinity"]

Build the image:

terminal
$docker build -t terminal-session:latest -f server/Dockerfile.session server/
1 commandbuun.group

Install Docker SDK

terminal
$cd server
$npm install dockerode
$npm install -D @types/dockerode
3 commandsbuun.group

Updated docker-compose.yml

Mount the Docker socket so the backend can spawn containers:

yaml
1services:
2 backend:
3 build:
4 context: ./server
5 dockerfile: Dockerfile
6 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:latest
12 - 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.sock
16 restart: unless-stopped

Updated Server with Docker Management

The key changes to server/index.ts:

typescript
1import Docker from 'dockerode';
2
3const 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');
7
8// Create container for a session
9async function createSessionContainer(sessionId: string): Promise<string> {
10 const containerName = `term-${sessionId.slice(0, 8)}`;
11
12 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 stopped
21 NetworkMode: 'none', // No network access for security
22 },
23 Labels: {
24 'terminal.session': sessionId,
25 },
26 });
27
28 await container.start();
29 return container.id;
30}
31
32// Attach to container shell
33async function attachToContainer(containerId: string, ws: WebSocket) {
34 const container = docker.getContainer(containerId);
35
36 const exec = await container.exec({
37 AttachStdin: true,
38 AttachStdout: true,
39 AttachStderr: true,
40 Tty: true,
41 Cmd: ['/bin/bash'],
42 });
43
44 const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
45
46 // Pipe container output to WebSocket
47 stream.on('data', (chunk: Buffer) => {
48 ws.send(JSON.stringify({ type: 'output', data: chunk.toString() }));
49 });
50
51 return stream;
52}
53
54// Destroy container on session end
55async function destroySessionContainer(containerId: string) {
56 const container = docker.getContainer(containerId);
57 await container.stop({ t: 2 }); // 2 second grace period
58 // AutoRemove handles deletion
59}

Session Flow

  1. Client connects with JWT token
  2. Backend validates token and extracts session ID
  3. Container spawned: term-abc12345 with resource limits
  4. Shell attached: docker exec into container
  5. I/O piped: Container stdout → WebSocket → Browser
  6. Session expires: Timer fires, container stopped and removed

Testing Container Isolation

Open two browser tabs and connect. Watch containers spawn:

terminal
$watch -n 1 'docker ps --filter "name=term-"'
1 commandbuun.group

You should see two separate containers. When sessions expire, they disappear.

Container Per Session Checklist
0/7
0% completebuun.group

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

flowchart

Containers

Backend

Browser

Session 1

Session 2

Session 3

Tab Bar

Terminal 1

Terminal 2

Terminal 3

WebSocket Server

term-abc123

term-def456

term-ghi789

Ctrl+scroll to zoom • Drag to pan51%

Session Management Hook

Create src/hooks/useTerminalSessions.ts to manage multiple sessions:

typescript
1import { useState, useCallback } from 'react';
2
3export interface TerminalSession {
4 id: string; // Local tab ID
5 token: string; // JWT token
6 sessionId: string; // Server session ID
7 expiresAt: Date;
8 name: string; // Display name
9 isActive: boolean;
10 isExpired: boolean;
11 isConnected: boolean;
12}
13
14interface 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}
24
25let sessionCounter = 0;
26
27export function useTerminalSessions(apiUrl: string): UseTerminalSessionsReturn {
28 const [sessions, setSessions] = useState<TerminalSession[]>([]);
29 const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
30
31 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]);
36
37 const addSession = useCallback(async () => {
38 const tokenData = await fetchToken();
39 if (!tokenData) return null;
40
41 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 };
52
53 setSessions((prev) => {
54 const updated = prev.map((s) => ({ ...s, isActive: false }));
55 return [...updated, newSession];
56 });
57
58 setActiveSessionId(newSession.id);
59 return newSession;
60 }, [fetchToken]);
61
62 const removeSession = useCallback((id: string) => {
63 setSessions((prev) => {
64 const filtered = prev.filter((s) => s.id !== id);
65
66 // Activate another tab if we closed the active one
67 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 }
74
75 return filtered;
76 });
77 }, [activeSessionId]);
78
79 const setActiveSession = useCallback((id: string) => {
80 setSessions((prev) =>
81 prev.map((s) => ({ ...s, isActive: s.id === id }))
82 );
83 setActiveSessionId(id);
84 }, []);
85
86 const updateSession = useCallback((id: string, updates: Partial<TerminalSession>) => {
87 setSessions((prev) =>
88 prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
89 );
90 }, []);
91
92 const markSessionExpired = useCallback((id: string) => {
93 updateSession(id, { isExpired: true, isConnected: false });
94 }, [updateSession]);
95
96 const refreshSession = useCallback(async (id: string) => {
97 const tokenData = await fetchToken();
98 if (!tokenData) return null;
99
100 setSessions((prev) =>
101 prev.map((s) =>
102 s.id === id
103 ? {
104 ...s,
105 token: tokenData.token,
106 sessionId: tokenData.sessionId,
107 expiresAt: new Date(tokenData.expiresAt),
108 isExpired: false,
109 }
110 : s
111 )
112 );
113
114 return sessions.find((s) => s.id === id) || null;
115 }, [fetchToken, sessions]);
116
117 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:

typescript
1import type { TerminalSession } from '../hooks/useTerminalSessions';
2
3interface 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}
11
12export function TabBar({
13 sessions,
14 activeSessionId,
15 onSelectTab,
16 onCloseTab,
17 onAddTab,
18 maxTabs = 5,
19}: TabBarProps) {
20 const canAddMore = sessions.length < maxTabs;
21
22 return (
23 <div
24 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 <div
36 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 === activeSessionId
45 ? 'rgba(255, 255, 255, 0.1)'
46 : 'transparent',
47 borderBottom:
48 session.id === activeSessionId
49 ? '2px solid #22c55e'
50 : '2px solid transparent',
51 cursor: 'pointer',
52 }}
53 >
54 {/* Status dot */}
55 <div
56 style={{
57 width: '6px',
58 height: '6px',
59 borderRadius: '50%',
60 backgroundColor: session.isExpired
61 ? '#ef4444' // Red: expired
62 : session.isConnected
63 ? '#22c55e' // Green: connected
64 : '#eab308', // Yellow: connecting
65 }}
66 />
67
68 {/* Tab name */}
69 <span
70 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>
79
80 {/* Close button */}
81 <button
82 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 ))}
100
101 {/* Add tab button */}
102 {canAddMore && (
103 <button
104 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 )}
120
121 {!canAddMore && (
122 <span style={{ fontSize: '10px', color: '#6b7280', marginLeft: '8px' }}>
123 Max {maxTabs} tabs
124 </span>
125 )}
126 </div>
127 );
128}

Updated App.tsx

Update src/App.tsx to support multiple tabs:

typescript
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';
9
10const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
11const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:3001';
12
13function App() {
14 const [isInitializing, setIsInitializing] = useState(true);
15
16 const { theme, themes, setThemeById } = useTerminalTheme();
17 const terminalSettings = useTerminalSettings();
18
19 // Multi-session management
20 const {
21 sessions,
22 activeSessionId,
23 addSession,
24 removeSession,
25 setActiveSession,
26 updateSession,
27 markSessionExpired,
28 refreshSession,
29 } = useTerminalSessions(API_URL);
30
31 const activeSession = sessions.find((s) => s.id === activeSessionId);
32
33 // Create initial session on mount
34 useEffect(() => {
35 const init = async () => {
36 await addSession();
37 setIsInitializing(false);
38 };
39 init();
40 }, []);
41
42 const handleConnectionChange = useCallback(
43 (sessionId: string, connected: boolean) => {
44 updateSession(sessionId, { isConnected: connected });
45 },
46 [updateSession]
47 );
48
49 const handleServerMessage = useCallback(
50 (sessionId: string, msg: { type: string }) => {
51 if (msg.type === 'session_expired') {
52 markSessionExpired(sessionId);
53 }
54 },
55 [markSessionExpired]
56 );
57
58 if (isInitializing) {
59 return <div>Starting terminal...</div>;
60 }
61
62 return (
63 <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
64 {/* Tab Bar */}
65 <TabBar
66 sessions={sessions}
67 activeSessionId={activeSessionId}
68 onSelectTab={setActiveSession}
69 onCloseTab={removeSession}
70 onAddTab={addSession}
71 maxTabs={5}
72 />
73
74 {/* Toolbar */}
75 <TerminalToolbar
76 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 />
87
88 {/* Terminal Container - render all, show only active */}
89 <div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
90 {sessions.map((session) => (
91 <div
92 key={session.id}
93 style={{
94 position: 'absolute',
95 inset: 0,
96 display: session.id === activeSessionId ? 'block' : 'none',
97 }}
98 >
99 {!session.isExpired ? (
100 <Terminal
101 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 <JwtExpiredDisplay
111 theme={theme}
112 onRequestNewSession={() => refreshSession(session.id)}
113 isLoading={false}
114 />
115 )}
116 </div>
117 ))}
118 </div>
119 </div>
120 );
121}
122
123export 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:

  1. Add tab: Fetch new JWT token → create session → spawn container
  2. Switch tab: Hide current terminal, show target (no network changes)
  3. Close tab: Disconnect WebSocket → container auto-destroyed
  4. Refresh expired: Fetch new token → replace session data → reconnect

Testing Multi-Tab

Multi-Tab Testing Checklist
0/9
0% completebuun.group

Watch containers as you add/remove tabs:

terminal
$watch -n 1 'docker ps --filter "name=term-" --format "table {{.Names}}\t{{.Status}}"'
1 commandbuun.group

Section 5: Advanced Features

Let's add the finishing touches that make this a truly professional terminal experience.

Feature Overview

Auto-Refresh
30s before
Shells
bash, sh, zsh
Tab Rename
Double-click
Process List
Per Tab
4 metricsbuun.group

1. Auto-Refresh Tokens

Instead of waiting for tokens to expire, refresh them proactively 30 seconds before:

typescript
1// In useTerminalSessions.ts
2
3// Auto-refresh buffer (refresh 30s before expiry)
4const REFRESH_BUFFER_MS = 30000;
5
6// Schedule auto-refresh for a session
7const scheduleAutoRefresh = useCallback((sessionId: string, expiresAt: Date) => {
8 // Clear existing timeout
9 const existingTimeout = refreshTimeouts.current.get(sessionId);
10 if (existingTimeout) {
11 clearTimeout(existingTimeout);
12 }
13
14 const timeUntilRefresh = expiresAt.getTime() - Date.now() - REFRESH_BUFFER_MS;
15
16 if (timeUntilRefresh > 0) {
17 const timeout = setTimeout(async () => {
18 console.log(`[${sessionId}] Auto-refreshing token (30s before expiry)`);
19
20 const tokenData = await fetchToken();
21 if (tokenData) {
22 setSessions((prev) =>
23 prev.map((s) =>
24 s.id === sessionId
25 ? {
26 ...s,
27 token: tokenData.token,
28 sessionId: tokenData.sessionId,
29 expiresAt: new Date(tokenData.expiresAt),
30 }
31 : s
32 )
33 );
34
35 // Schedule next refresh
36 scheduleAutoRefresh(sessionId, new Date(tokenData.expiresAt));
37 }
38 }, timeUntilRefresh);
39
40 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):

typescript
1/**
2 * Shared TypeScript types for the terminal application
3 */
4
5// Shell configuration
6export type ShellType = 'bash' | 'sh' | 'zsh';
7
8export interface ShellConfig {
9 id: ShellType;
10 name: string;
11 command: string;
12}
13
14// Process information from container
15export interface ProcessInfo {
16 pid: string;
17 user: string;
18 cpu: string;
19 mem: string;
20 command: string;
21}
22
23// Terminal session state
24export 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}
36
37// WebSocket message types
38export 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}
48
49// Token response from API
50export interface TokenResponse {
51 token: string;
52 sessionId: string;
53 expiresAt: number;
54}

Shell Configuration (src/config/shells.ts):

typescript
1/**
2 * Shell configuration for terminal sessions
3 */
4
5import type { ShellType } from '../types';
6
7export interface ShellConfig {
8 id: ShellType;
9 name: string;
10 command: string;
11}
12
13export 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];
30
31export const DEFAULT_SHELL: ShellType = 'bash';
32
33export function getShellById(id: ShellType): ShellConfig {
34 return SHELLS.find((s) => s.id === id) || SHELLS[0];
35}
36
37export function getShellCommand(id: ShellType): string {
38 return getShellById(id).command;
39}

ShellSelector Component (src/components/ShellSelector.tsx):

typescript
1/**
2 * Shell Selector Component
3 *
4 * Allows users to switch between different shell types (bash, sh, zsh).
5 * Uses react-icons for consistent iconography.
6 */
7
8import { 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';
14
15interface ShellSelectorProps {
16 currentShell: ShellType;
17 onShellChange: (shell: ShellType) => void;
18 theme: TerminalTheme;
19 disabled?: boolean;
20}
21
22// Shell icon mapping
23const SHELL_ICONS: Record<ShellType, typeof VscTerminalBash> = {
24 bash: VscTerminalBash,
25 sh: VscTerminal,
26 zsh: VscSymbolMethod,
27};
28
29export 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);
37
38 // Close dropdown when clicking outside
39 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 }, []);
48
49 const currentShellConfig = SHELLS.find((s) => s.id === currentShell) || SHELLS[0];
50 const CurrentIcon = SHELL_ICONS[currentShell];
51
52 return (
53 <div ref={dropdownRef} style={{ position: 'relative' }}>
54 <button
55 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>
78
79 {isOpen && !disabled && (
80 <div
81 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 <button
98 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:

typescript
1// server/index.ts - Accept shell type from WebSocket query
2
3const SHELL_COMMANDS: Record<string, string> = {
4 bash: '/bin/bash',
5 sh: '/bin/sh',
6 zsh: '/bin/zsh',
7};
8
9wss.on('connection', async (ws, req) => {
10 const url = new URL(req.url || '/', `http://localhost:${PORT}`);
11 const shellType = url.searchParams.get('shell') || 'bash';
12
13 // Use selected shell when attaching to container
14 const shellCmd = SHELL_COMMANDS[shellType] || SHELL_COMMANDS.bash;
15
16 const exec = await container.exec({
17 Cmd: [shellCmd],
18 Env: [`SHELL=${shellCmd}`, 'TERM=xterm-256color'],
19 // ...
20 });
21});

Update Dockerfile.session to include zsh:

dockerfile
1RUN apk add --no-cache \
2 bash \
3 zsh \ # Add zsh
4 procps \ # For ps command
5 # ... other packages

3. Tab Renaming

Double-click on a tab name to rename it:

typescript
1// In TabBar.tsx
2
3const [editingTabId, setEditingTabId] = useState<string | null>(null);
4const [editText, setEditText] = useState('');
5
6const handleDoubleClick = (tabId: string, currentName: string) => {
7 setEditingTabId(tabId);
8 setEditText(currentName);
9};
10
11const handleRenameCommit = () => {
12 if (editingTabId && editText.trim()) {
13 onRenameTab(editingTabId, editText.trim());
14 }
15 setEditingTabId(null);
16 setEditText('');
17};
18
19// In render:
20{editingTabId === session.id ? (
21 <input
22 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 autoFocus
32 />
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:

typescript
1// TabSettingsPanel.tsx
2
3interface 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}
13
14export 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>
26
27 <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>
41
42 <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:

typescript
1// server/index.ts
2
3async function getContainerProcesses(containerId: string): Promise<ProcessInfo[]> {
4 const container = docker.getContainer(containerId);
5
6 const exec = await container.exec({
7 Cmd: ['ps', 'aux', '--no-headers'],
8 AttachStdout: true,
9 });
10
11 const stream = await exec.start({ hijack: true, stdin: false });
12
13 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}
33
34// 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

Advanced Features Checklist
0/8
0% completebuun.group

Section 6: Production Polish

The final touches for a truly production-ready terminal: quick actions, command history, and proper error handling.

Quick Actions
20+
History
Export
Offline
Handled
3 metricsbuun.group

1. Server Offline Display

Show a friendly error when the server is unreachable:

typescript
1// src/components/ServerOfflineDisplay.tsx
2
3import { FiAlertTriangle, FiRefreshCw } from 'react-icons/fi';
4import type { TerminalTheme } from '../config/themes';
5
6interface ServerOfflineDisplayProps {
7 theme: TerminalTheme;
8 onRetry?: () => void;
9 isRetrying?: boolean;
10}
11
12export function ServerOfflineDisplay({
13 theme,
14 onRetry,
15 isRetrying = false,
16}: ServerOfflineDisplayProps) {
17 return (
18 <div
19 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 &gt; Terminal Server Offline
35 </p>
36 <p style={{ color: '#9ca3af', marginBottom: '24px' }}>
37 &gt; Unable to establish connection. Please try again.
38 </p>
39 {onRetry && (
40 <button
41 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:

typescript
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}, []);
9
10useEffect(() => {
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):

typescript
1/**
2 * Quick Actions Configuration
3 *
4 * Predefined commands for common terminal operations.
5 * Organized by category for easy access.
6 */
7
8import 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';
24
25export type ActionCategory =
26 | 'System'
27 | 'Shell'
28 | 'Utilities'
29 | 'Development'
30 | 'Git'
31 | 'Networking'
32 | 'Monitoring';
33
34export interface QuickAction {
35 id: string;
36 name: string;
37 command: string;
38 icon: IconType;
39 category: ActionCategory;
40 description?: string;
41 keywords?: string[];
42}
43
44export const QUICK_ACTIONS: QuickAction[] = [
45 // System
46 {
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 },
73
74 // Git
75 {
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 },
93
94 // Networking
95 {
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 },
113
114 // Utilities
115 {
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 },
124
125 // Monitoring
126 {
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];
136
137export const ACTION_CATEGORIES: ActionCategory[] = [
138 'System',
139 'Shell',
140 'Utilities',
141 'Development',
142 'Git',
143 'Networking',
144 'Monitoring',
145];
146
147export function getActionsByCategory(category: ActionCategory): QuickAction[] {
148 return QUICK_ACTIONS.filter((action) => action.category === category);
149}
150
151export 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):

typescript
1/**
2 * Quick Actions Panel Component
3 *
4 * Provides quick access to common terminal commands.
5 * Searchable and categorized for easy navigation.
6 */
7
8import { 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';
18
19interface QuickActionsPanelProps {
20 theme: TerminalTheme;
21 onExecuteCommand: (command: string) => void;
22 onClose: () => void;
23}
24
25export 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 );
34
35 // Filter actions based on search
36 const filteredActions = useMemo(() => {
37 if (searchQuery.trim()) {
38 return searchActions(searchQuery);
39 }
40 return null; // Show categorized view
41 }, [searchQuery]);
42
43 // Toggle category expansion
44 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 };
55
56 // Execute action
57 const handleExecute = (action: QuickAction) => {
58 onExecuteCommand(action.command);
59 onClose();
60 };
61
62 const renderAction = (action: QuickAction) => {
63 const Icon = action.icon;
64 return (
65 <button
66 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 };
98
99 return (
100 <div
101 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 <input
118 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 autoFocus
124 />
125 </div>
126 </div>
127
128 {/* 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);
137
138 return (
139 <div key={category}>
140 <button
141 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>
154
155 <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 execute
157 </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):

typescript
1/**
2 * Command History Panel Component
3 *
4 * Displays and exports command history for the current session.
5 * Supports JSON and CSV export formats.
6 */
7
8import { FiClock, FiTerminal, FiDownload, FiTrash2, FiGitBranch } from 'react-icons/fi';
9import { VscTerminalBash } from 'react-icons/vsc';
10import type { TerminalTheme } from '../config/themes';
11
12export interface CommandHistoryEntry {
13 command: string;
14 timestamp: Date;
15}
16
17interface CommandHistoryPanelProps {
18 history: CommandHistoryEntry[];
19 theme: TerminalTheme;
20 onClearHistory?: () => void;
21 onClose: () => void;
22}
23
24// Get icon based on command prefix
25function 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}
31
32export function CommandHistoryPanel({
33 history,
34 theme,
35 onClearHistory,
36}: CommandHistoryPanelProps) {
37 // Download file helper
38 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 };
47
48 // Export as JSON
49 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 };
56
57 // Export as CSV
58 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 };
67
68 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 };
82
83 return (
84 <div
85 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 <div
99 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 History
112 </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>
118
119 {/* 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>
146
147 {/* 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} /> JSON
153 </button>
154 <button onClick={handleExportCSV} style={buttonStyle}>
155 <FiDownload size={12} /> CSV
156 </button>
157 </div>
158 {onClearHistory && (
159 <button onClick={onClearHistory} style={{ ...buttonStyle, borderColor: 'rgba(239, 68, 68, 0.3)' }}>
160 <FiTrash2 size={12} /> Clear
161 </button>
162 )}
163 </div>
164 )}
165 </div>
166 );
167}

4. Integrating into the Toolbar

Add buttons to TerminalToolbar:

typescript
1// Updated TerminalToolbar.tsx
2
3import { FiZap, FiClock } from 'react-icons/fi';
4import { QuickActionsPanel } from './QuickActionsPanel';
5import { CommandHistoryPanel, type CommandHistoryEntry } from './CommandHistoryPanel';
6
7interface TerminalToolbarProps {
8 // ... existing props
9 onExecuteCommand?: (command: string) => void;
10 commandHistory?: CommandHistoryEntry[];
11 onClearHistory?: () => void;
12}
13
14export function TerminalToolbar({ /* props */ }) {
15 const [showQuickActions, setShowQuickActions] = useState(false);
16 const [showHistory, setShowHistory] = useState(false);
17
18 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>
25
26 {/* 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>
34
35 {/* Popover panels */}
36 {showQuickActions && (
37 <QuickActionsPanel
38 theme={currentTheme}
39 onExecuteCommand={onExecuteCommand}
40 onClose={() => setShowQuickActions(false)}
41 />
42 )}
43
44 {showHistory && (
45 <CommandHistoryPanel
46 history={commandHistory}
47 theme={currentTheme}
48 onClearHistory={onClearHistory}
49 onClose={() => setShowHistory(false)}
50 />
51 )}
52 </div>
53 );
54}

Testing Production Features

Production Polish Checklist
0/9
0% completebuun.group

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:

typescript
1// src/App.tsx - Add guard for StrictMode double-initialization
2
3import { useRef } from 'react';
4
5function App() {
6 // Guard against StrictMode double-initialization
7 const hasInitializedRef = useRef(false);
8
9 useEffect(() => {
10 // Prevent double-initialization in React StrictMode
11 if (hasInitializedRef.current) {
12 console.log('[App] Skipping duplicate initialization (StrictMode)');
13 return;
14 }
15 hasInitializedRef.current = true;
16
17 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 }
25
26 const session = await addSession();
27 if (!session) {
28 setServerOffline(true);
29 }
30 setIsInitializing(false);
31 console.log('[App] Initialization complete');
32 };
33 init();
34 }, []);
35
36 // ... rest of component
37}

2. WebSocket Connection Guards

The WebSocket hook needs guards to prevent duplicate connections and handle URL changes correctly:

typescript
1// src/hooks/useWebSocket.ts - Full production version
2
3import { useEffect, useRef, useState, useCallback } from 'react';
4
5export 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}
16
17export 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);
22
23 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);
28
29 // CRITICAL: Track if initial connection has been made
30 // Prevents URL change effect from firing on mount
31 const hasConnectedRef = useRef(false);
32
33 // CRITICAL: Track if we're currently connecting
34 // Prevents duplicate connections from StrictMode
35 const isConnectingRef = useRef(false);
36
37 // Store latest values in refs
38 const urlRef = useRef(url);
39 const optionsRef = useRef(options);
40 const previousUrlRef = useRef(url);
41
42 useEffect(() => { urlRef.current = url; }, [url]);
43 useEffect(() => { optionsRef.current = options; }, [options]);
44
45 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 }, []);
50
51 const connectFnRef = useRef<() => void>(() => {});
52
53 const connect = useCallback(() => {
54 if (!mountedRef.current) return;
55
56 // CRITICAL: Prevent duplicate connections
57 if (isConnectingRef.current) {
58 console.log('[useWebSocket] Already connecting, skipping');
59 return;
60 }
61 isConnectingRef.current = true;
62
63 // Clean up existing connection
64 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 }
72
73 if (reconnectTimeoutRef.current) {
74 clearTimeout(reconnectTimeoutRef.current);
75 reconnectTimeoutRef.current = null;
76 }
77
78 const ws = new WebSocket(urlRef.current);
79 ws.binaryType = 'arraybuffer';
80 wsRef.current = ws;
81 setWsInstance(ws);
82
83 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 };
93
94 ws.onmessage = (event) => {
95 if (!mountedRef.current) return;
96 optionsRef.current.onMessage?.(event);
97 };
98
99 ws.onclose = (event) => {
100 if (!mountedRef.current) return;
101 isConnectingRef.current = false;
102 setIsConnected(false);
103 setWsInstance(null);
104
105 if (shouldReconnectRef.current) {
106 optionsRef.current.onClose?.(event);
107 }
108
109 // Don't auto-reconnect on auth failures
110 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 }
116
117 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);
124
125 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 };
135
136 ws.onerror = (event) => {
137 if (!mountedRef.current) return;
138 isConnectingRef.current = false;
139 optionsRef.current.onError?.(event);
140 };
141 }, [getReconnectDelay]);
142
143 connectFnRef.current = connect;
144
145 // Initial connection - runs once on mount
146 useEffect(() => {
147 mountedRef.current = true;
148 shouldReconnectRef.current = true;
149 hasConnectedRef.current = false;
150 isConnectingRef.current = false;
151
152 connectFnRef.current();
153
154 return () => {
155 mountedRef.current = false;
156 shouldReconnectRef.current = false;
157 isConnectingRef.current = false;
158
159 if (reconnectTimeoutRef.current) {
160 clearTimeout(reconnectTimeoutRef.current);
161 }
162
163 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 mount
173
174 // Reconnect when URL changes (e.g., token refresh)
175 // CRITICAL: Only triggers AFTER initial connection
176 useEffect(() => {
177 const urlChanged = previousUrlRef.current !== url;
178 previousUrlRef.current = url;
179
180 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]);
188
189 // ... send, reconnect, disconnect methods
190 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:

typescript
1// src/components/Terminal.tsx - Safe write methods
2
3export 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);
8
9 // CRITICAL: Track disposed state to prevent xterm crashes
10 const isDisposedRef = useRef(false);
11
12 // Safe terminal write - checks if terminal exists and is not disposed
13 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 }, []);
23
24 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 }, []);
34
35 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 }, []);
45
46 // Initialize terminal with delayed ready state
47 useEffect(() => {
48 if (!terminalRef.current) return;
49 if (xtermRef.current) return; // Prevent re-initialization
50
51 isDisposedRef.current = false;
52
53 const term = new XTerm({
54 cursorBlink: settings.cursorBlink,
55 fontSize: settings.fontSize,
56 fontFamily: settings.fontFamily,
57 theme: theme.colors,
58 allowProposedApi: true,
59 });
60
61 const fitAddon = new FitAddon();
62 const webLinksAddon = new WebLinksAddon();
63 term.loadAddon(fitAddon);
64 term.loadAddon(webLinksAddon);
65
66 term.open(terminalRef.current);
67 xtermRef.current = term;
68 fitAddonRef.current = fitAddon;
69
70 // CRITICAL: Delay fit and ready state to ensure terminal is fully rendered
71 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);
86
87 return () => {
88 clearTimeout(readyTimeout);
89 isDisposedRef.current = true;
90 setIsTerminalReady(false);
91
92 try {
93 term.dispose();
94 } catch (e) {
95 console.warn('[Terminal] Dispose failed:', e);
96 }
97
98 xtermRef.current = null;
99 fitAddonRef.current = null;
100 };
101 }, []); // Only initialize once
102
103 // Use safeWrite/safeWriteln in WebSocket callbacks
104 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 safeWrite
112 },
113 onClose: () => {
114 safeWriteln('\x1b[31mConnection closed\x1b[0m');
115 },
116 });
117
118 // ... rest of component
119}

4. Backend Session Replacement

The backend should replace existing sessions instead of rejecting them, handling StrictMode reconnects gracefully:

typescript
1// server/index.ts - Replace session instead of rejecting
2
3wss.on('connection', async (ws: WebSocket, req: IncomingMessage) => {
4 // ... token validation ...
5
6 const { sessionId, expiresAt } = payload;
7
8 // CRITICAL: Replace existing session instead of rejecting
9 // Handles React StrictMode double-mount and page refreshes
10 if (sessions.has(sessionId)) {
11 console.log(`[${sessionId}] Replacing existing session (StrictMode or reconnect)`);
12 const existingSession = sessions.get(sessionId)!;
13
14 // Clear timeouts
15 clearTimeout(existingSession.expiryTimeout);
16 clearInterval(existingSession.heartbeat);
17
18 // Close old exec stream
19 if (existingSession.execStream) {
20 try { existingSession.execStream.end(); } catch {}
21 }
22
23 // Close old WebSocket without triggering cleanup
24 if (existingSession.ws.readyState === WebSocket.OPEN) {
25 existingSession.ws.close(4002, 'Replaced by new connection');
26 }
27
28 // Destroy old container (async, don't wait)
29 destroySessionContainer(sessionId, existingSession.containerId).catch(() => {});
30
31 // Remove from sessions map
32 sessions.delete(sessionId);
33 }
34
35 // Continue with new session creation...
36});

5. Unique Container Names

Avoid Docker container name conflicts during rapid reconnection:

typescript
1// server/index.ts - Add timestamp suffix to container names
2
3async function createSessionContainer(sessionId: string): Promise<string> {
4 // Use timestamp suffix to avoid name conflicts during StrictMode double-mount
5 const containerName = `term-${sessionId.slice(0, 8)}-${Date.now().toString(36)}`;
6
7 console.log(`[${sessionId}] Creating container: ${containerName}`);
8
9 const container = await docker.createContainer({
10 Image: SESSION_IMAGE,
11 name: containerName,
12 // ... rest of config
13 });
14
15 await container.start();
16 return container.id;
17}

6. Environment Configuration

Ensure session duration is longer than the refresh buffer:

terminal
$.env - Session must be > refresh buffer (30s)
SESSION_DURATION_MS=300000 # 5 minutes
1 commandbuun.group

Testing Bugfixes

Bugfix Verification Checklist
0/6
0% completebuun.group

Summary

You now have a production-ready browser terminal with:

Themes
6
JWT Auth
Auto-refresh
Containers
Per Session
Bugfixes
StrictMode Safe
4 metricsbuun.group
Progress
7 events
Section 1

UI & Theming

Theme system, settings, status indicators

Section 2

JWT Session Tokens

Token auth with auto-expiry

Section 3

Docker Per Session

Isolated containers per user

Section 4

Multi-Terminal Tabs

Tab interface for multiple sessions

Section 5

Advanced Features

Auto-refresh, shells, rename, process list

Section 6

Production Polish

Quick actions, history, offline handling

Section 7

Production Bugfixes

StrictMode guards, race conditions, container conflicts

newest firstbuun.group

Full Source Code

The complete source code for this tutorial is available on GitHub:

View Full Source

Need Production Terminal Infrastructure?

Topics

xterm.js themesterminal themingReact terminal UIbrowser terminal settingsxterm.js customizationterminal font settingsWebSocket terminalproduction terminal

Share this post

Share

Comments

Sign in to join the conversation

Login

No comments yet. Be the first to share your thoughts!

Found an issue with this article?

/ Let's Talk

Want to work with us?

Whether you need help with architecture, development, or technical consulting, our team is here to help bring your vision to life.