mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-31 10:25:57 +00:00
- Create renderer-entry.ts to bundle both Renderer and XTermRenderer - Add bundle:renderer script to generate public/bundle/renderer.js - Update all test files to import from ../bundle/renderer.js - Remove all unpkg/CDN XTerm.js script imports - Tests now use bundled dependencies for faster loading 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
654 lines
No EOL
25 KiB
JavaScript
654 lines
No EOL
25 KiB
JavaScript
"use strict";
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
const express_1 = __importDefault(require("express"));
|
|
const http_1 = require("http");
|
|
const ws_1 = require("ws");
|
|
const path_1 = __importDefault(require("path"));
|
|
const fs_1 = __importDefault(require("fs"));
|
|
const os_1 = __importDefault(require("os"));
|
|
const child_process_1 = require("child_process");
|
|
const app = (0, express_1.default)();
|
|
const server = (0, http_1.createServer)(app);
|
|
const wss = new ws_1.WebSocketServer({ server });
|
|
const PORT = process.env.PORT || 3000;
|
|
// tty-fwd binary path - check multiple possible locations
|
|
const possibleTtyFwdPaths = [
|
|
path_1.default.resolve(__dirname, '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
|
path_1.default.resolve(__dirname, '..', '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
|
'tty-fwd' // System PATH
|
|
];
|
|
let TTY_FWD_PATH = '';
|
|
for (const pathToCheck of possibleTtyFwdPaths) {
|
|
if (fs_1.default.existsSync(pathToCheck)) {
|
|
TTY_FWD_PATH = pathToCheck;
|
|
break;
|
|
}
|
|
}
|
|
if (!TTY_FWD_PATH) {
|
|
console.error('tty-fwd binary not found. Please ensure it is built and available.');
|
|
process.exit(1);
|
|
}
|
|
const TTY_FWD_CONTROL_DIR = process.env.TTY_FWD_CONTROL_DIR || path_1.default.join(os_1.default.homedir(), '.vibetunnel');
|
|
// Ensure control directory exists and is clean
|
|
if (fs_1.default.existsSync(TTY_FWD_CONTROL_DIR)) {
|
|
// Clean existing directory contents
|
|
try {
|
|
const files = fs_1.default.readdirSync(TTY_FWD_CONTROL_DIR);
|
|
for (const file of files) {
|
|
const filePath = path_1.default.join(TTY_FWD_CONTROL_DIR, file);
|
|
const stat = fs_1.default.statSync(filePath);
|
|
if (stat.isDirectory()) {
|
|
fs_1.default.rmSync(filePath, { recursive: true, force: true });
|
|
}
|
|
else {
|
|
fs_1.default.unlinkSync(filePath);
|
|
}
|
|
}
|
|
console.log(`Cleaned control directory: ${TTY_FWD_CONTROL_DIR}`);
|
|
}
|
|
catch (error) {
|
|
console.error('Error cleaning control directory:', error);
|
|
}
|
|
}
|
|
else {
|
|
fs_1.default.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true });
|
|
console.log(`Created control directory: ${TTY_FWD_CONTROL_DIR}`);
|
|
}
|
|
console.log(`Using tty-fwd at: ${TTY_FWD_PATH}`);
|
|
console.log(`Control directory: ${TTY_FWD_CONTROL_DIR}`);
|
|
// Helper function to execute tty-fwd commands
|
|
async function executeTtyFwd(args) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = (0, child_process_1.spawn)(TTY_FWD_PATH, args);
|
|
let output = '';
|
|
let isResolved = false;
|
|
// Set a timeout to prevent hanging
|
|
const timeout = setTimeout(() => {
|
|
if (!isResolved) {
|
|
isResolved = true;
|
|
child.kill('SIGTERM');
|
|
reject(new Error('tty-fwd command timed out after 5 seconds'));
|
|
}
|
|
}, 5000);
|
|
child.stdout.on('data', (data) => {
|
|
output += data.toString();
|
|
});
|
|
child.on('close', (code) => {
|
|
if (!isResolved) {
|
|
isResolved = true;
|
|
clearTimeout(timeout);
|
|
if (code === 0) {
|
|
resolve(output);
|
|
}
|
|
else {
|
|
reject(new Error(`tty-fwd failed with code ${code}`));
|
|
}
|
|
}
|
|
});
|
|
child.on('error', (error) => {
|
|
if (!isResolved) {
|
|
isResolved = true;
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
// Helper function to resolve paths with ~ expansion
|
|
function resolvePath(inputPath, fallback) {
|
|
if (!inputPath) {
|
|
return fallback || process.cwd();
|
|
}
|
|
if (inputPath.startsWith('~')) {
|
|
return path_1.default.join(os_1.default.homedir(), inputPath.slice(1));
|
|
}
|
|
return path_1.default.resolve(inputPath);
|
|
}
|
|
// Middleware
|
|
app.use(express_1.default.json());
|
|
app.use(express_1.default.static(path_1.default.join(__dirname, '..', 'public')));
|
|
// Hot reload functionality for development
|
|
const hotReloadClients = new Set();
|
|
// === SESSION MANAGEMENT ===
|
|
// List all sessions
|
|
app.get('/api/sessions', async (req, res) => {
|
|
try {
|
|
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
|
const sessions = JSON.parse(output || '{}');
|
|
const sessionData = Object.entries(sessions).map(([sessionId, sessionInfo]) => {
|
|
// Get actual last modified time from stream-out file
|
|
let lastModified = sessionInfo.started_at;
|
|
try {
|
|
if (fs_1.default.existsSync(sessionInfo["stream-out"])) {
|
|
const stats = fs_1.default.statSync(sessionInfo["stream-out"]);
|
|
lastModified = stats.mtime.toISOString();
|
|
}
|
|
}
|
|
catch (e) {
|
|
// Use started_at as fallback
|
|
}
|
|
return {
|
|
id: sessionId,
|
|
command: sessionInfo.cmdline.join(' '),
|
|
workingDir: sessionInfo.cwd,
|
|
status: sessionInfo.status,
|
|
exitCode: sessionInfo.exit_code,
|
|
startedAt: sessionInfo.started_at,
|
|
lastModified: lastModified,
|
|
pid: sessionInfo.pid
|
|
};
|
|
});
|
|
// Sort by lastModified, most recent first
|
|
sessionData.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
res.json(sessionData);
|
|
}
|
|
catch (error) {
|
|
console.error('Failed to list sessions:', error);
|
|
res.status(500).json({ error: 'Failed to list sessions' });
|
|
}
|
|
});
|
|
// Create new session
|
|
app.post('/api/sessions', async (req, res) => {
|
|
try {
|
|
const { command, workingDir } = req.body;
|
|
if (!command || !Array.isArray(command) || command.length === 0) {
|
|
return res.status(400).json({ error: 'Command array is required and cannot be empty' });
|
|
}
|
|
const sessionName = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
const cwd = resolvePath(workingDir, process.cwd());
|
|
const args = [
|
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
'--session-name', sessionName,
|
|
'--'
|
|
].concat(command);
|
|
console.log(`Creating session: ${TTY_FWD_PATH} ${args.join(' ')}`);
|
|
const child = (0, child_process_1.spawn)(TTY_FWD_PATH, args, {
|
|
cwd: cwd,
|
|
detached: false,
|
|
stdio: 'pipe'
|
|
});
|
|
// Log output for debugging
|
|
child.stdout.on('data', (data) => {
|
|
console.log(`Session ${sessionName} stdout:`, data.toString());
|
|
});
|
|
child.stderr.on('data', (data) => {
|
|
console.log(`Session ${sessionName} stderr:`, data.toString());
|
|
});
|
|
child.on('close', (code) => {
|
|
console.log(`Session ${sessionName} exited with code: ${code}`);
|
|
});
|
|
// Respond immediately - don't wait for completion
|
|
res.json({ sessionId: sessionName });
|
|
}
|
|
catch (error) {
|
|
console.error('Error creating session:', error);
|
|
res.status(500).json({ error: 'Failed to create session' });
|
|
}
|
|
});
|
|
// Kill session (just kill the process)
|
|
app.delete('/api/sessions/:sessionId', async (req, res) => {
|
|
const sessionId = req.params.sessionId;
|
|
try {
|
|
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
|
const sessions = JSON.parse(output || '{}');
|
|
const session = sessions[sessionId];
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
if (session.pid) {
|
|
try {
|
|
process.kill(session.pid, 'SIGTERM');
|
|
setTimeout(() => {
|
|
try {
|
|
process.kill(session.pid, 0); // Check if still alive
|
|
process.kill(session.pid, 'SIGKILL'); // Force kill
|
|
}
|
|
catch (e) {
|
|
// Process already dead
|
|
}
|
|
}, 1000);
|
|
}
|
|
catch (error) {
|
|
// Process already dead
|
|
}
|
|
}
|
|
res.json({ success: true, message: 'Session killed' });
|
|
}
|
|
catch (error) {
|
|
console.error('Error killing session:', error);
|
|
res.status(500).json({ error: 'Failed to kill session' });
|
|
}
|
|
});
|
|
// Cleanup session files
|
|
app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
|
|
const sessionId = req.params.sessionId;
|
|
try {
|
|
await executeTtyFwd([
|
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
'--session', sessionId,
|
|
'--cleanup'
|
|
]);
|
|
res.json({ success: true, message: 'Session cleaned up' });
|
|
}
|
|
catch (error) {
|
|
// If tty-fwd cleanup fails, force remove directory
|
|
console.log('tty-fwd cleanup failed, force removing directory');
|
|
const sessionDir = path_1.default.join(TTY_FWD_CONTROL_DIR, sessionId);
|
|
try {
|
|
if (fs_1.default.existsSync(sessionDir)) {
|
|
fs_1.default.rmSync(sessionDir, { recursive: true, force: true });
|
|
}
|
|
res.json({ success: true, message: 'Session force cleaned up' });
|
|
}
|
|
catch (fsError) {
|
|
console.error('Error force removing session directory:', fsError);
|
|
res.status(500).json({ error: 'Failed to cleanup session' });
|
|
}
|
|
}
|
|
});
|
|
// Cleanup all exited sessions
|
|
app.post('/api/cleanup-exited', async (req, res) => {
|
|
try {
|
|
await executeTtyFwd([
|
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
'--cleanup'
|
|
]);
|
|
res.json({ success: true, message: 'All exited sessions cleaned up' });
|
|
}
|
|
catch (error) {
|
|
console.error('Error cleaning up exited sessions:', error);
|
|
res.status(500).json({ error: 'Failed to cleanup exited sessions' });
|
|
}
|
|
});
|
|
// === TERMINAL I/O ===
|
|
// Track active streams per session to avoid multiple tail processes
|
|
const activeStreams = new Map();
|
|
// Live streaming cast file for asciinema player
|
|
app.get('/api/sessions/:sessionId/stream', async (req, res) => {
|
|
const sessionId = req.params.sessionId;
|
|
const streamOutPath = path_1.default.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
|
if (!fs_1.default.existsSync(streamOutPath)) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
console.log(`New SSE client connected to session ${sessionId}`);
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
|
});
|
|
const startTime = Date.now() / 1000;
|
|
let headerSent = false;
|
|
// Send existing content first
|
|
try {
|
|
const content = fs_1.default.readFileSync(streamOutPath, 'utf8');
|
|
const lines = content.trim().split('\n');
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
if (parsed.version && parsed.width && parsed.height) {
|
|
res.write(`data: ${line}\n\n`);
|
|
headerSent = true;
|
|
}
|
|
else if (Array.isArray(parsed) && parsed.length >= 3) {
|
|
const instantEvent = [0, parsed[1], parsed[2]];
|
|
res.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
|
|
}
|
|
}
|
|
catch (e) {
|
|
// Skip invalid lines
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (error) {
|
|
console.error('Error reading existing content:', error);
|
|
}
|
|
// Send default header if none found
|
|
if (!headerSent) {
|
|
const defaultHeader = {
|
|
version: 2,
|
|
width: 80,
|
|
height: 24,
|
|
timestamp: Math.floor(startTime),
|
|
env: { TERM: "xterm-256color" }
|
|
};
|
|
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
|
}
|
|
// Get or create shared stream for this session
|
|
let streamInfo = activeStreams.get(sessionId);
|
|
if (!streamInfo) {
|
|
console.log(`Creating new shared tail process for session ${sessionId}`);
|
|
// Create new tail process for this session
|
|
const tailProcess = (0, child_process_1.spawn)('tail', ['-f', streamOutPath]);
|
|
let buffer = '';
|
|
streamInfo = {
|
|
clients: new Set(),
|
|
tailProcess,
|
|
lastPosition: 0
|
|
};
|
|
activeStreams.set(sessionId, streamInfo);
|
|
// Handle tail output - broadcast to all clients
|
|
tailProcess.stdout.on('data', (chunk) => {
|
|
buffer += chunk.toString();
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
let eventData;
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
if (parsed.version && parsed.width && parsed.height) {
|
|
continue; // Skip duplicate headers
|
|
}
|
|
if (Array.isArray(parsed) && parsed.length >= 3) {
|
|
const currentTime = Date.now() / 1000;
|
|
const realTimeEvent = [currentTime - startTime, parsed[1], parsed[2]];
|
|
eventData = `data: ${JSON.stringify(realTimeEvent)}\n\n`;
|
|
}
|
|
}
|
|
catch (e) {
|
|
// Handle non-JSON as raw output
|
|
const currentTime = Date.now() / 1000;
|
|
const castEvent = [currentTime - startTime, "o", line];
|
|
eventData = `data: ${JSON.stringify(castEvent)}\n\n`;
|
|
}
|
|
if (eventData && streamInfo) {
|
|
// Broadcast to all connected clients
|
|
streamInfo.clients.forEach(client => {
|
|
try {
|
|
client.write(eventData);
|
|
}
|
|
catch (error) {
|
|
console.error('Error writing to client:', error);
|
|
if (streamInfo) {
|
|
streamInfo.clients.delete(client);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
tailProcess.on('error', (error) => {
|
|
console.error(`Shared tail process error for session ${sessionId}:`, error);
|
|
// Cleanup all clients
|
|
const currentStreamInfo = activeStreams.get(sessionId);
|
|
if (currentStreamInfo) {
|
|
currentStreamInfo.clients.forEach(client => {
|
|
try {
|
|
client.end();
|
|
}
|
|
catch (e) { }
|
|
});
|
|
}
|
|
activeStreams.delete(sessionId);
|
|
});
|
|
tailProcess.on('exit', (code) => {
|
|
console.log(`Shared tail process exited for session ${sessionId} with code ${code}`);
|
|
// Cleanup all clients
|
|
const currentStreamInfo = activeStreams.get(sessionId);
|
|
if (currentStreamInfo) {
|
|
currentStreamInfo.clients.forEach(client => {
|
|
try {
|
|
client.end();
|
|
}
|
|
catch (e) { }
|
|
});
|
|
}
|
|
activeStreams.delete(sessionId);
|
|
});
|
|
}
|
|
// Add this client to the shared stream
|
|
streamInfo.clients.add(res);
|
|
console.log(`Added client to session ${sessionId}, total clients: ${streamInfo.clients.size}`);
|
|
// Cleanup when client disconnects
|
|
const cleanup = () => {
|
|
if (streamInfo && streamInfo.clients.has(res)) {
|
|
streamInfo.clients.delete(res);
|
|
console.log(`Removed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
|
|
// If no more clients, cleanup the tail process
|
|
if (streamInfo.clients.size === 0) {
|
|
console.log(`No more clients for session ${sessionId}, cleaning up tail process`);
|
|
try {
|
|
streamInfo.tailProcess.kill('SIGTERM');
|
|
}
|
|
catch (e) { }
|
|
activeStreams.delete(sessionId);
|
|
}
|
|
}
|
|
};
|
|
req.on('close', cleanup);
|
|
req.on('aborted', cleanup);
|
|
});
|
|
// Get session snapshot (asciinema cast with adjusted timestamps for immediate playback)
|
|
app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
|
const sessionId = req.params.sessionId;
|
|
const streamOutPath = path_1.default.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
|
if (!fs_1.default.existsSync(streamOutPath)) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
try {
|
|
const content = fs_1.default.readFileSync(streamOutPath, 'utf8');
|
|
const lines = content.trim().split('\n');
|
|
let header = null;
|
|
const events = [];
|
|
let startTime = null;
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
// Header line
|
|
if (parsed.version && parsed.width && parsed.height) {
|
|
header = parsed;
|
|
}
|
|
// Event line [timestamp, type, data]
|
|
else if (Array.isArray(parsed) && parsed.length >= 3) {
|
|
if (startTime === null) {
|
|
startTime = parsed[0];
|
|
}
|
|
events.push([0, parsed[1], parsed[2]]);
|
|
}
|
|
}
|
|
catch (e) {
|
|
// Skip invalid lines
|
|
}
|
|
}
|
|
}
|
|
// Build the complete asciinema cast
|
|
const cast = [];
|
|
// Add header if found, otherwise use default
|
|
if (header) {
|
|
cast.push(JSON.stringify(header));
|
|
}
|
|
else {
|
|
cast.push(JSON.stringify({
|
|
version: 2,
|
|
width: 80,
|
|
height: 24,
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
env: { TERM: "xterm-256color" }
|
|
}));
|
|
}
|
|
// Add all events
|
|
events.forEach(event => {
|
|
cast.push(JSON.stringify(event));
|
|
});
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.send(cast.join('\n'));
|
|
}
|
|
catch (error) {
|
|
console.error('Error reading session snapshot:', error);
|
|
res.status(500).json({ error: 'Failed to read session snapshot' });
|
|
}
|
|
});
|
|
// Send input to session
|
|
app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
|
const sessionId = req.params.sessionId;
|
|
const { text } = req.body;
|
|
if (text === undefined || text === null) {
|
|
return res.status(400).json({ error: 'Text is required' });
|
|
}
|
|
console.log(`Sending input to session ${sessionId}:`, JSON.stringify(text));
|
|
try {
|
|
// Validate session exists and is running
|
|
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
|
const sessions = JSON.parse(output || '{}');
|
|
if (!sessions[sessionId]) {
|
|
console.error(`Session ${sessionId} not found in active sessions`);
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
const session = sessions[sessionId];
|
|
if (session.status !== 'running') {
|
|
console.error(`Session ${sessionId} is not running (status: ${session.status})`);
|
|
return res.status(400).json({ error: 'Session is not running' });
|
|
}
|
|
// Check if the process is actually still alive
|
|
if (session.pid) {
|
|
try {
|
|
process.kill(session.pid, 0); // Signal 0 just checks if process exists
|
|
}
|
|
catch (error) {
|
|
console.error(`Session ${sessionId} process ${session.pid} is dead, cleaning up`);
|
|
// Try to cleanup the stale session
|
|
try {
|
|
await executeTtyFwd([
|
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
'--session', sessionId,
|
|
'--cleanup'
|
|
]);
|
|
}
|
|
catch (cleanupError) {
|
|
console.error('Failed to cleanup stale session:', cleanupError);
|
|
}
|
|
return res.status(410).json({ error: 'Session process has died' });
|
|
}
|
|
}
|
|
// Check if this is a special key that should use --send-key
|
|
const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter'];
|
|
const isSpecialKey = specialKeys.includes(text);
|
|
const startTime = Date.now();
|
|
if (isSpecialKey) {
|
|
await executeTtyFwd([
|
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
'--session', sessionId,
|
|
'--send-key', text
|
|
]);
|
|
console.log(`Successfully sent key: ${text} (${Date.now() - startTime}ms)`);
|
|
}
|
|
else {
|
|
await executeTtyFwd([
|
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
'--session', sessionId,
|
|
'--send-text', text
|
|
]);
|
|
console.log(`Successfully sent text: ${text} (${Date.now() - startTime}ms)`);
|
|
}
|
|
res.json({ success: true });
|
|
}
|
|
catch (error) {
|
|
console.error('Error sending input via tty-fwd:', error);
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
res.status(500).json({ error: 'Failed to send input', details: errorMessage });
|
|
}
|
|
});
|
|
// === CAST FILE SERVING ===
|
|
// Serve test cast file
|
|
app.get('/api/test-cast', (req, res) => {
|
|
const testCastPath = path_1.default.join(__dirname, '..', 'public', 'stream-out');
|
|
try {
|
|
if (fs_1.default.existsSync(testCastPath)) {
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
const content = fs_1.default.readFileSync(testCastPath, 'utf8');
|
|
res.send(content);
|
|
}
|
|
else {
|
|
res.status(404).json({ error: 'Test cast file not found' });
|
|
}
|
|
}
|
|
catch (error) {
|
|
console.error('Error serving test cast file:', error);
|
|
res.status(500).json({ error: 'Failed to serve test cast file' });
|
|
}
|
|
});
|
|
// === FILE SYSTEM ===
|
|
// Directory listing for file browser
|
|
app.get('/api/fs/browse', (req, res) => {
|
|
const dirPath = req.query.path || '~';
|
|
try {
|
|
const expandedPath = resolvePath(dirPath, '~');
|
|
if (!fs_1.default.existsSync(expandedPath)) {
|
|
return res.status(404).json({ error: 'Directory not found' });
|
|
}
|
|
const stats = fs_1.default.statSync(expandedPath);
|
|
if (!stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Path is not a directory' });
|
|
}
|
|
const files = fs_1.default.readdirSync(expandedPath).map(name => {
|
|
const filePath = path_1.default.join(expandedPath, name);
|
|
const fileStats = fs_1.default.statSync(filePath);
|
|
return {
|
|
name,
|
|
created: fileStats.birthtime.toISOString(),
|
|
lastModified: fileStats.mtime.toISOString(),
|
|
size: fileStats.size,
|
|
isDir: fileStats.isDirectory()
|
|
};
|
|
});
|
|
res.json({
|
|
absolutePath: expandedPath,
|
|
files: files.sort((a, b) => {
|
|
// Directories first, then files
|
|
if (a.isDir && !b.isDir)
|
|
return -1;
|
|
if (!a.isDir && b.isDir)
|
|
return 1;
|
|
return a.name.localeCompare(b.name);
|
|
})
|
|
});
|
|
}
|
|
catch (error) {
|
|
console.error('Error listing directory:', error);
|
|
res.status(500).json({ error: 'Failed to list directory' });
|
|
}
|
|
});
|
|
// === WEBSOCKETS ===
|
|
// WebSocket for hot reload
|
|
wss.on('connection', (ws, req) => {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const isHotReload = url.searchParams.get('hotReload') === 'true';
|
|
if (isHotReload) {
|
|
hotReloadClients.add(ws);
|
|
ws.on('close', () => {
|
|
hotReloadClients.delete(ws);
|
|
});
|
|
return;
|
|
}
|
|
ws.close(1008, 'Only hot reload connections supported');
|
|
});
|
|
// Hot reload file watching in development
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
const chokidar = require('chokidar');
|
|
const watcher = chokidar.watch(['public/**/*', 'src/**/*'], {
|
|
ignored: /node_modules/,
|
|
persistent: true
|
|
});
|
|
watcher.on('change', (path) => {
|
|
console.log(`File changed: ${path}`);
|
|
hotReloadClients.forEach((ws) => {
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'reload' }));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
server.listen(PORT, () => {
|
|
console.log(`VibeTunnel New Server running on http://localhost:${PORT}`);
|
|
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
|
|
});
|
|
//# sourceMappingURL=server.js.map
|