I am developing a web-based terminal in a Next.js application. The frontend communicates with an API (/api/socket
) that opens a WebSocket session with a Go application. The Go application establishes a WebSocket connection to a Kubernetes Pod using kubectl exec
. While basic commands like ping
, ls
, and cd
work fine, commands such as vi
, vim
, and nano
are not functioning as expected in Xterm.js. How can I make these commands work properly?
The Terminal component supports navigating through previously executed commands using the up/down arrow keys. It also handles Ctrl+C or Ctrl+Z to exit commands like ping. Additionally, the component uses the FitAddon to enable copy and paste functionality.
Here is my terminal component
import React, { useEffect, useRef, useState } from 'react';
import '../../../../node_modules/@xterm/xterm/css/xterm.css';
import io from 'socket.io-client';
const Terminal = ({ accountId, data, requestId }) => {
const terminalRef = useRef(null);
const xtermRef = useRef(null);
const socketRef = useRef(null);
const commandHistoryRef = useRef([]);
const isConnectedRef = useRef(null);
const [currentInput, setCurrentInput] = useState('');
let promptText = '';
const fetchInitialData = async () => {
try {
const response = await fetch('/api/socket');
if (!response.ok) {
throw new Error('Network response was not ok');
}
initializeWebSocket();
} catch (error) {
console.error('Fetch error:', error);
}
};
const initializeWebSocket = () => {
socketRef.current = io();
socketRef.current.on('connect', () => {
console.log('Connected to Socket.IO server');
xtermRef.current.writeln('Initializing...');
socketRef.current.emit('checkConnection');
});
socketRef.current.on('connectionReady', () => {
try {
xtermRef.current.writeln('Connecting...');
const payload = JSON.stringify({
no_sinks: true,
body: {
account_id: accountId,
action_name: 'interactive_session',
action_params: {
name: data.name,
namespace: data.namespace,
},
origin: 'Nudgebee UI',
},
request_id: requestId,
});
socketRef.current.emit('initialCommand', payload);
if (xtermRef.current) {
xtermRef.current.clear();
}
} catch (error) {
console.error('Error in emit:', error);
xtermRef.current.writeln('Failed to send message');
}
});
socketRef.current.on('connect_error', (error) => {
console.error('Connection error:', error);
xtermRef.current.writeln('Failed to connect to pod');
});
socketRef.current.on('commandOutput', (data) => {
if (xtermRef.current) {
let folder = '';
isConnectedRef.current = true;
const parsedJson = JSON.parse(data);
const infoStream = parsedJson?.data?.info_stream ?? '';
const errorStream = parsedJson?.data?.error_stream ?? '';
if (infoStream) {
const lines = infoStream.split('\n');
folder = lines[lines.length - 1];
lines.slice(0, -1).forEach((line) => xtermRef.current.writeln(line));
} else if (errorStream) {
errorStream.split('\n').forEach((line) => xtermRef.current.writeln(line));
}
writeNewPrompt(folder);
}
});
};
const writeNewPrompt = (folder) => {
promptText = folder || '';
xtermRef.current.write(folder || ``);
};
useEffect(() => {
fetchInitialData();
}, []);
useEffect(() => {
if (terminalRef.current) {
const { Terminal } = require('@xterm/xterm');
const { FitAddon } = require('@xterm/addon-fit');
xtermRef.current = new Terminal({
cursorBlink: true,
allowTransparency: true,
cursorInactiveStyle: 'bar',
});
const fitAddon = new FitAddon();
xtermRef.current.loadAddon(fitAddon);
xtermRef.current.open(terminalRef.current);
setTimeout(() => {
fitAddon.fit();
}, 0); // Small delay
let currentLine = '';
let localHistoryIndex = -1;
xtermRef.current.attachCustomKeyEventHandler((event) => {
if (event.ctrlKey && event.key === 'c' && xtermRef.current.hasSelection()) {
const selection = xtermRef.current.getSelection();
navigator.clipboard.writeText(selection);
return false;
}
return true;
});
xtermRef.current.attachCustomKeyEventHandler((event) => {
if (event.ctrlKey && event.key === 'v') {
navigator.clipboard.readText().then((text) => {
xtermRef.current.write(text);
currentLine += text;
});
return false;
}
return true;
});
xtermRef.current.onKey(({ key, domEvent }) => {
const printable = !domEvent.altKey && !domEvent.ctrlKey && !domEvent.metaKey;
if (!isConnectedRef.current) {
return;
}
const forbiddenCommands = ['vim', 'vi', 'nano'];
if (domEvent.keyCode === 13) {
xtermRef.current.writeln('');
if (currentLine.trim().length > 0) {
const command = currentLine.trim();
if (forbiddenCommands.some((forbidden) => command.startsWith(forbidden))) {
xtermRef.current.writeln(Currently Command "${command}" is not supported.
);
xtermRef.current.writeln('');
currentLine = '';
return;
}
commandHistoryRef.current = [...commandHistoryRef.current, command];
setCurrentInput('');
localHistoryIndex = -1;
currentLine = '';
if (socketRef.current) {
socketRef.current.emit(
'sendCommand',
JSON.stringify({
no_sinks: true,
body: {
account_id: accountId,
action_name: 'interactive_session',
action_params: {
name: data.name,
namespace: data.namespace,
command: command,
},
origin: 'Nudgebee UI',
},
request_id: requestId,
})
);
}
} else {
xtermRef.current.write(\x1b[2K\r${promptText}
);
}
} else if (domEvent.keyCode === 8) {
if (currentLine.length > 0) {
currentLine = currentLine.slice(0, -1);
xtermRef.current.write('\b \b');
}
} else if (domEvent.keyCode === 38) {
if (localHistoryIndex < commandHistoryRef.current.length - 1) {
if (localHistoryIndex === -1) {
setCurrentInput(currentLine);
}
localHistoryIndex++;
const newCommand = commandHistoryRef.current[commandHistoryRef.current.length - 1 - localHistoryIndex];
xtermRef.current.write(\x1b[2K\r${promptText}${newCommand}
);
currentLine = newCommand;
}
} else if (domEvent.keyCode === 40) {
if (localHistoryIndex > -1) {
localHistoryIndex--;
let newCommand;
if (localHistoryIndex === -1) {
newCommand = currentInput;
} else {
newCommand = commandHistoryRef.current[commandHistoryRef.current.length - 1 - localHistoryIndex];
}
xtermRef.current.write(\x1b[2K\r${promptText}${newCommand}
);
currentLine = newCommand;
}
} else if (printable) {
currentLine += key;
xtermRef.current.write(key);
} else if (domEvent.ctrlKey && (domEvent.key === 'c' || domEvent.key === 'z')) {
xtermRef.current.writeln('^C');
currentLine = '';
if (socketRef.current) {
socketRef.current.emit(
'sendCommand',
JSON.stringify({
no_sinks: true,
body: {
account_id: accountId,
action_name: 'interactive_session',
action_params: {
name: data.name,
namespace: data.namespace,
command: '\x03',
},
origin: 'Nudgebee UI',
},
request_id: requestId,
})
);
}
}
});
const handleResize = () => {
fitAddon.fit();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (socketRef.current) {
console.log('Disconnecting socket');
const payload = JSON.stringify({
no_sinks: true,
body: {
account_id: accountId,
action_name: 'interactive_session',
action_params: {
name: data.name,
namespace: data.namespace,
command: 'exit',
},
origin: 'Nudgebee UI',
},
request_id: requestId,
});
socketRef.current.emit('sendCommand', payload);
socketRef.current.disconnect();
}
if (xtermRef.current) {
xtermRef.current.dispose();
}
};
}
}, []);
return <div className='terminal' ref={terminalRef} style={{ width: '100%', height: '400px', marginLeft: '30px', marginTop: '20px' }} />;
};
export default Terminal;
Here is my socket.js API
import { Server } from 'socket.io';
import WebSocket from 'ws';
const SocketHandler = (req, res) => {
const goWSEndpoint = 'ws://localhost:52832/ws';
const secretKey = '';
if (res.socket.server.io) {
console.log('Socket is already running');
res.end();
return;
}
console.log('Socket is initializing');
const io = new Server(res.socket.server, {
pingTimeout: 60000,
pingInterval: 25000,
});
res.socket.server.io = io;
io.on('connection', (socket) => {
console.log(New client connected: ${socket.id}
);
const ws = new WebSocket(goWSEndpoint, {
headers: {
'X-SECRET-KEY': secretKey,
},
});
ws.on('open', () => {
socket.emit('connectionReady', JSON.stringify({ connection: 'ready' }));
socket.on('initialCommand', (jsonMessage) => {
try {
console.log('Received JSON message from client for initial connection:', jsonMessage);
ws.send(jsonMessage);
} catch (error) {
console.error('Error in initialCommand:', error);
}
});
socket.on('sendCommand', (jsonMessage) => {
try {
console.log('Received JSON message from client:', jsonMessage);
ws.send(jsonMessage);
} catch (error) {
console.error('Error in sendCommand:', error);
}
});
});
ws.on('message', (data) => {
console.log(Received message from Go WebSocket server: ${data}
);
socket.emit('commandOutput', data.toString('utf-8'));
});
ws.on('error', (error) => {
console.error('Go WebSocket error:', error);
});
ws.on('close', () => {
console.log('Go Connection closed');
socket.emit(
'commandOutput',
JSON.stringify({
data: {
info_stream: 'Connection closed',
},
})
);
});
socket.on('disconnect', (reason) => {
console.log(Client disconnected: ${socket.id}, reason: ${reason}
);
console.log(Clearing interval for client ${socket.id}
);
});
socket.on('error', (error) => {
console.error(Error for client ${socket.id}:
, error);
});
});
io.on('connect_error', (err) => {
console.error('Connection error:', err);
});
console.log('Socket.IO server initialized');
res.end();
};
export const config = {
api: {
bodyParser: false,
},
};
export default SocketHandler;
The Go WebSocket successfully returns the response for the ls -al command, providing the detailed directory listing.
{"action": "response", "request_id": "6712fc75-b252-45e9-9e3d-7fc57f56e4fa", "status_code": 200, "data": {"info_stream": "ls -al\r\ntotal 16\r\ndrwxr-xr-x 1 root root 19 Sep 19 03:03 \u001b[1;34m.\u001b[m\r\ndrwxr-xr-x 1 root root 40 Sep 19 03:20 \u001b[1;34m..\u001b[m", "error_stream": null, "session_id": "6712fc75-b252-45e9-9e3d-7fc57f56e4fa", "exit": false}, "output_type": "interactive_session"}
{"action": "response", "request_id": "6712fc75-b252-45e9-9e3d-7fc57f56e4fa", "status_code": 200, "data": {"info_stream": "\r\ndrwxr-xr-x 1 nextjs nodejs 20 Sep 19 03:03 \u001b[1;34m.next\u001b[m\r\ndrwxr-xr-x 83 nextjs nodejs 4096 Sep 19 03:03 \u001b[1;34mnode_modules\u001b[m\r\n-rw-r--r-- 1 nextjs nodejs 3572 Sep 19 03:03 \u001b[0;0mpackage.json\u001b[m\r\ndrwxr-xr-x 4 root root 198 Sep 19 03:03 \u001b[1;34mpublic\u001b[m\r\n-rw-r--r-- 1 nextjs nodejs 4543 Sep 19 03:03 \u001b[0;0mserver.js\u001b[m\r\ndrwxr-xr-x 3 nextjs nodejs 19 Sep 19 03:03 \u001b[1;34msrc\u001b[m\r\n", "error_stream": null, "session_id": "6712fc75-b252-45e9-9e3d-7fc57f56e4fa", "exit": false}, "output_type": "interactive_session"}