feat: add webshell
This commit is contained in:
		| @@ -3,7 +3,7 @@ | ||||
| <meta charset="UTF-8"> | ||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
| <title>AI Apps</title> | ||||
| <link rel="stylesheet" href="./src/assets/index.css"> | ||||
| <link rel="stylesheet" href="./src/index.css"> | ||||
| <style> | ||||
|   html, | ||||
|   body { | ||||
| @@ -22,10 +22,19 @@ | ||||
| </style> | ||||
| <!-- <script src="/system/lib/app.js"></script> --> | ||||
| <script src="https://kevisual.xiongxiao.me/system/lib/app.js"></script> | ||||
| <style> | ||||
|   #terminal { | ||||
|     background-color: #000; | ||||
|     height: 100%; | ||||
|   } | ||||
| </style> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|   <div id="ai-root"></div> | ||||
|   <div id="ai-root"> | ||||
|  | ||||
|     <div id="terminal" style="width: 100%; height: 100%;"></div> | ||||
|   </div> | ||||
|   <!-- <div id="ai-bot-root"></div> --> | ||||
| </body> | ||||
| <script src="./src/main.ts" type="module"></script> | ||||
|   | ||||
| @@ -4,10 +4,16 @@ | ||||
|   "description": "", | ||||
|   "main": "index.js", | ||||
|   "scripts": { | ||||
|     "test": "echo \"Error: no test specified\" && exit 1" | ||||
|     "dev": "vite", | ||||
|     "build": "vite build", | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "keywords": [], | ||||
|   "author": "abearxiong <xiongxiao@xiongxiao.me>", | ||||
|   "license": "MIT", | ||||
|   "type": "module" | ||||
|   "type": "module", | ||||
|   "dependencies": { | ||||
|     "@xterm/xterm": "^5.5.0", | ||||
|     "socket.io-client": "^4.8.1" | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										60
									
								
								packages/webshell/src/main.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								packages/webshell/src/main.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| import { Terminal } from '@xterm/xterm'; | ||||
| // import { FitAddon } from 'xterm-addon-fit'; | ||||
| import '@xterm/xterm/css/xterm.css'; | ||||
| import io from 'socket.io-client'; | ||||
| const term = new Terminal(); | ||||
| term.open(document.getElementById('terminal') as HTMLElement); | ||||
| // term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ '); | ||||
| let input = ''; | ||||
| term.onData((data) => { | ||||
|   // socket.emit('input', { name: 'tab', data }); | ||||
|   if (data === '\b' || data === '\x7F') { | ||||
|     console.log('blank data\b'); | ||||
|     input = input.slice(0, -1); | ||||
|     term.write('\b \b'); | ||||
|     return; | ||||
|   } | ||||
|   input += data; | ||||
|   if (data === '\r') { | ||||
|     console.log('input', input); | ||||
|     socket.emit('tab-input', input); | ||||
|     input = ''; | ||||
|   }  | ||||
|   term.write(data); | ||||
| }); | ||||
| // term.on | ||||
| term.onResize(({ cols, rows }) => { | ||||
|   term.resize(cols, rows); | ||||
| }); | ||||
|  | ||||
| const socket = io(window.location.origin + '/terminal', { path: '/terminal', reconnection: true }); | ||||
| socket.connect(); | ||||
| const termialTab0 = { name: 'tab', children: [] }; | ||||
| const createTerminal = (tab: any) => { | ||||
|   const name = tab.name; | ||||
|   const cwd = null; | ||||
|   socket.emit('create', { name: name }); | ||||
| }; | ||||
| socket.on('connect', () => { | ||||
|   console.log('connect'); | ||||
|   createTerminal(termialTab0); | ||||
| }); | ||||
| socket.on('message', (data: any) => { | ||||
|   console.log('message', data); | ||||
| }); | ||||
|  | ||||
| socket.on('tab-output', (data: any) => { | ||||
|   console.log('tab-output', data); | ||||
|   term.write(data); | ||||
| }); | ||||
| socket.on('tab-pid', (data: any) => { | ||||
|   console.log('tab-pid-data', data); | ||||
| }); | ||||
|  | ||||
| const inputEl = document.querySelector('#input') as HTMLInputElement; | ||||
| const send = document.querySelector('#send'); | ||||
|  | ||||
| send?.addEventListener('click', () => { | ||||
|   const data = inputEl?.value; | ||||
|   socket.emit('tab-input', data); | ||||
| }); | ||||
| @@ -50,7 +50,6 @@ export default defineConfig({ | ||||
|       // 'react-dom/client': 'https://cdn.jsdelivr.net/npm/react-dom/client/+esm', | ||||
|       // react: 'https://cdn.jsdelivr.net/npm/react@19.0.0/+esm', | ||||
|       // 'react-dom': 'https://cdn.jsdelivr.net/npm/react-dom@19.0.0/+esm', | ||||
|        | ||||
|     }, | ||||
|   }, | ||||
|   define: { | ||||
| @@ -68,6 +67,11 @@ export default defineConfig({ | ||||
|     port: 6022, | ||||
|     host: '0.0.0.0', | ||||
|     proxy: { | ||||
|       '/terminal': { | ||||
|         target: 'http://localhost:3000', | ||||
|         changeOrigin: true, | ||||
|         ws: true, | ||||
|       }, | ||||
|       '/system/lib': { | ||||
|         target: 'https://kevisual.xiongxiao.me', | ||||
|         changeOrigin: true, | ||||
|   | ||||
							
								
								
									
										45
									
								
								packages/webshell/webshell-node/bin/webshell
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								packages/webshell/webshell-node/bin/webshell
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| #!/usr/bin/env node | ||||
| process.env.NODE_ENV = 'production'; | ||||
| const http = require('http'); | ||||
| const args = require('args'); | ||||
| const app = require('../app'); | ||||
| const createSocketServer = require('../socket/index'); | ||||
|  | ||||
| args.option('port', 'The port on which the app will be running', 3000); | ||||
| const flags = args.parse(process.argv); | ||||
| let port = flags.port; | ||||
|  | ||||
| app.set('port', port); | ||||
|  | ||||
| let server = http.createServer(app); | ||||
|  | ||||
| createSocketServer(server); | ||||
|  | ||||
| server.listen(port); | ||||
| server.on('error', onError); | ||||
|  | ||||
| /** | ||||
|  * Event listener for HTTP server "error" event. | ||||
|  */ | ||||
|  | ||||
| function onError(error) { | ||||
|     if (error.syscall !== 'listen') { | ||||
|         throw error; | ||||
|     } | ||||
|  | ||||
|     var bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port; | ||||
|  | ||||
|     // handle specific listen errors with friendly messages | ||||
|     switch (error.code) { | ||||
|         case 'EACCES': | ||||
|             console.error(bind + ' requires elevated privileges'); | ||||
|             process.exit(1); | ||||
|             break; | ||||
|         case 'EADDRINUSE': | ||||
|             console.error(bind + ' is already in use'); | ||||
|             process.exit(1); | ||||
|             break; | ||||
|         default: | ||||
|             throw error; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										22
									
								
								packages/webshell/webshell-node/demos/test-install/demo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								packages/webshell/webshell-node/demos/test-install/demo.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| import * as pty from 'node-pty'; | ||||
|  | ||||
| export function createPty(cmd: string) { | ||||
|   const ptyProcess = pty.spawn(cmd, [], { | ||||
|     name: 'xterm-color', | ||||
|     cols: 80, | ||||
|     rows: 30, | ||||
|     cwd: process.env.HOME, | ||||
|     env: process.env, | ||||
|   }); | ||||
|  | ||||
|   ptyProcess.onData((data) => { | ||||
|     process.stdout.write(data); | ||||
|   }); | ||||
|  | ||||
|   ptyProcess.write('ls\r'); | ||||
|   ptyProcess.resize(100, 40); | ||||
|   ptyProcess.write('ls\r'); | ||||
| } | ||||
|  | ||||
| // createPty('ls'); | ||||
| createPty('zsh'); | ||||
							
								
								
									
										1609
									
								
								packages/webshell/webshell-node/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1609
									
								
								packages/webshell/webshell-node/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -11,9 +11,20 @@ | ||||
|   "license": "MIT", | ||||
|   "type": "module", | ||||
|   "dependencies": { | ||||
|     "node-pty": "^1.0.0" | ||||
|     "args": "5.0.3", | ||||
|     "body-parser": "1.20.3", | ||||
|     "ejs": "3.1.10", | ||||
|     "express": "4.21.2", | ||||
|     "morgan": "1.10.0", | ||||
|     "node-pty": "^1.0.0", | ||||
|     "socket.io": "4.8.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@kevisual/types": "^0.0.6" | ||||
|     "@kevisual/types": "^0.0.6", | ||||
|     "@types/args": "^5.0.3", | ||||
|     "@types/body-parser": "^1.19.5", | ||||
|     "@types/ejs": "^3.1.5", | ||||
|     "@types/express": "^5.0.0", | ||||
|     "@types/morgan": "^1.9.9" | ||||
|   } | ||||
| } | ||||
| } | ||||
							
								
								
									
										61
									
								
								packages/webshell/webshell-node/src/app.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								packages/webshell/webshell-node/src/app.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| import express, { Request, Response, NextFunction } from 'express'; | ||||
| import path from 'path'; | ||||
| import logger from 'morgan'; | ||||
| import bodyParser from 'body-parser'; | ||||
| import { renderFile } from 'ejs'; | ||||
|  | ||||
| import indexRouter from './routes/index'; | ||||
|  | ||||
| const app = express(); | ||||
|  | ||||
| // view engine setup | ||||
| app.set('views', path.join(process.cwd(), 'public')); | ||||
| app.set('view engine', 'html'); | ||||
| app.engine('html', renderFile); | ||||
|  | ||||
| app.use(logger('dev')); | ||||
| app.use(bodyParser.json()); // for parsing application/json | ||||
| app.use(bodyParser.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded | ||||
| app.use(express.json()); | ||||
| app.use(express.urlencoded({ extended: false })); | ||||
| app.use(express.static(path.join(process.cwd(), 'public'))); | ||||
|  | ||||
| app.use((req: Request, res: Response, next: NextFunction) => { | ||||
|   // @ts-ignore | ||||
|   res.success = function (data: any) { | ||||
|     res.json({ | ||||
|       code: 0, | ||||
|       msg: '操作成功', | ||||
|       data: data, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   // @ts-ignore | ||||
|   res.fail = function (message: string) { | ||||
|     res.json({ | ||||
|       code: 1, | ||||
|       msg: message, | ||||
|     }); | ||||
|   }; | ||||
|   next(); | ||||
| }); | ||||
|  | ||||
| app.use('/api', indexRouter); | ||||
|  | ||||
| app.use('*', (req: Request, res: Response) => { | ||||
|   res.render('index', { title: 'Express' }); | ||||
| }); | ||||
|  | ||||
| // error handler | ||||
| app.use((err: any, req: Request, res: Response, next: NextFunction) => { | ||||
|   // set locals, only providing error in development | ||||
|   res.locals.message = err.message; | ||||
|   res.locals.error = req.app.get('env') === 'development' ? err : {}; | ||||
|  | ||||
|   // render the error page | ||||
|   res.status(err.status || 500); | ||||
|   // res.render('error'); | ||||
|   res.send(err); | ||||
| }); | ||||
|  | ||||
| export default app; | ||||
| @@ -1,20 +0,0 @@ | ||||
| import os from 'node:os'; | ||||
| import pty, { IPty } from 'node-pty'; | ||||
|  | ||||
| const shell: string = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; | ||||
| console.log(shell); | ||||
| const ptyProcess: IPty = pty.spawn(shell, [], { | ||||
|   name: 'xterm-color', | ||||
|   cols: 80, | ||||
|   rows: 30, | ||||
|   cwd: process.env.HOME || '', | ||||
|   env: process.env as NodeJS.ProcessEnv, | ||||
| }); | ||||
|  | ||||
| ptyProcess.onData((data: string) => { | ||||
|   process.stdout.write(data); | ||||
| }); | ||||
|  | ||||
| ptyProcess.write('ls\r'); | ||||
| ptyProcess.resize(100, 40); | ||||
| ptyProcess.write('ls\r'); | ||||
							
								
								
									
										21
									
								
								packages/webshell/webshell-node/src/routes/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								packages/webshell/webshell-node/src/routes/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import express, { Request, Response } from 'express'; | ||||
| import { exec } from 'child_process'; | ||||
| import { promisify } from 'util'; | ||||
|  | ||||
| const router = express.Router(); | ||||
| const promiseExec = promisify(exec); | ||||
|  | ||||
| router.get('/cwd', async (req: Request, res: Response) => { | ||||
|   const { pid } = req.query; | ||||
|   try { | ||||
|     const { stdout } = await promiseExec(`lsof -a -p ${pid} -d cwd -Fn | tail -1 | sed 's/.//'`); | ||||
|     const cwd = stdout.trim(); | ||||
|     // @ts-ignore | ||||
|     res.success(cwd); | ||||
|   } catch (error) { | ||||
|     // @ts-ignore | ||||
|     res.fail('Failed to retrieve current working directory'); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| export default router; | ||||
							
								
								
									
										12
									
								
								packages/webshell/webshell-node/src/socket/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								packages/webshell/webshell-node/src/socket/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { Server as HttpServer } from 'http'; | ||||
| import { SocketServer } from './socket'; | ||||
| import { terminal } from './terminal'; | ||||
|  | ||||
| export const createSocketServer = (server: HttpServer) => { | ||||
|   const socketServer = new SocketServer(server, { | ||||
|     path: '/terminal', | ||||
|     pingTimeout: 1000 * 60 * 60 * 24, | ||||
|   }); | ||||
|  | ||||
|   socketServer.use('terminal', terminal); | ||||
| }; | ||||
							
								
								
									
										23
									
								
								packages/webshell/webshell-node/src/socket/socket.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/webshell/webshell-node/src/socket/socket.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import { Server as HttpServer } from 'http'; | ||||
| import { ServerOptions, Server as SocketIoServer, Namespace, Socket } from 'socket.io'; | ||||
|  | ||||
| export class SocketServer { | ||||
|     private io: SocketIoServer; | ||||
|  | ||||
|     constructor(server: HttpServer, options?: Partial<ServerOptions>) { | ||||
|         this.io = new SocketIoServer(server, options); | ||||
|     } | ||||
|  | ||||
|     use(name: string | ((socket: Socket) => void), fn?: (socket: Socket) => void): boolean { | ||||
|         if (!name) return false; | ||||
|         if (typeof name === 'string') { | ||||
|             if (!fn) return false; | ||||
|             if (typeof fn !== 'function') throw new TypeError('middleware must be a function!'); | ||||
|  | ||||
|             this.io.of(name).on('connection', fn); | ||||
|         } else if (typeof name === 'function') { | ||||
|             this.io.on('connection', name); | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
| }  | ||||
							
								
								
									
										49
									
								
								packages/webshell/webshell-node/src/socket/terminal.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								packages/webshell/webshell-node/src/socket/terminal.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| import * as pty from 'node-pty'; | ||||
| import * as os from 'os'; | ||||
| import { homedir } from 'os'; | ||||
| import { Socket } from 'socket.io'; | ||||
|  | ||||
| const shell: string = os.platform() === 'win32' ? 'powershell.exe' : 'zsh'; | ||||
|  | ||||
| interface Option { | ||||
|   name: string; | ||||
|   cols?: number; | ||||
|   rows?: number; | ||||
|   cwd?: string; | ||||
| } | ||||
|  | ||||
| let ptyContainers: { [key: string]: pty.IPty } = {}; | ||||
|  | ||||
| export const terminal = (socket: Socket) => { | ||||
|   socket.on('create', (option: Option) => { | ||||
|     let ptyProcess = pty.spawn(shell, ['--login'], { | ||||
|       name: 'xterm-color', | ||||
|       cols: option.cols || 80, | ||||
|       rows: option.rows || 24, | ||||
|       cwd: option.cwd || homedir(), | ||||
|       env: process.env, | ||||
|     }) as any; | ||||
|     console.log('create', option.name); | ||||
|     ptyProcess.on('data', (data: string) => socket.emit(option.name + '-output', data)); | ||||
|     socket.on(option.name + '-input', (data: string) => ptyProcess.write(data)); | ||||
|     socket.on(option.name + '-resize', (size: [number, number]) => { | ||||
|       ptyProcess.resize(size[0], size[1]); | ||||
|     }); | ||||
|     socket.on(option.name + '-exit', () => { | ||||
|       console.log('exit', option.name) | ||||
|       ptyProcess.destroy(); | ||||
|     }); | ||||
|     socket.emit(option.name + '-pid', ptyProcess.pid); | ||||
|     ptyContainers[option.name] = ptyProcess; | ||||
|   }); | ||||
|   socket.on('remove', (name: string) => { | ||||
|     socket.removeAllListeners(name + '-input'); | ||||
|     socket.removeAllListeners(name + '-resize'); | ||||
|     socket.removeAllListeners(name + '-exit'); | ||||
|     if (name && ptyContainers[name] && ptyContainers[name].pid) { | ||||
|       const curentContainer = ptyContainers[name] as any; | ||||
|       curentContainer.destroy(); | ||||
|       delete ptyContainers[name]; | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
							
								
								
									
										47
									
								
								packages/webshell/webshell-node/src/www.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								packages/webshell/webshell-node/src/www.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| #!/usr/bin/env node | ||||
| process.env.NODE_ENV = 'production'; | ||||
| import http from 'http'; | ||||
| import args from 'args'; | ||||
| import app from './app'; | ||||
| import { createSocketServer } from './socket/index'; | ||||
|  | ||||
| args.option('port', 'The port on which the app will be running', 3000); | ||||
| const flags = args.parse(process.argv); | ||||
| let port: number = flags.port; | ||||
|  | ||||
| app.set('port', port); | ||||
|  | ||||
| let server = http.createServer(app); | ||||
|  | ||||
| createSocketServer(server); | ||||
|  | ||||
| server.listen(port, () => { | ||||
|   console.log(`Server is running on port ${port}`); | ||||
| }); | ||||
| server.on('error', onError); | ||||
|  | ||||
| /** | ||||
|  * Event listener for HTTP server "error" event. | ||||
|  */ | ||||
|  | ||||
| function onError(error: NodeJS.ErrnoException): void { | ||||
|   if (error.syscall !== 'listen') { | ||||
|     throw error; | ||||
|   } | ||||
|  | ||||
|   const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port; | ||||
|  | ||||
|   // handle specific listen errors with friendly messages | ||||
|   switch (error.code) { | ||||
|     case 'EACCES': | ||||
|       console.error(bind + ' requires elevated privileges'); | ||||
|       process.exit(1); | ||||
|       break; | ||||
|     case 'EADDRINUSE': | ||||
|       console.error(bind + ' is already in use'); | ||||
|       process.exit(1); | ||||
|       break; | ||||
|     default: | ||||
|       throw error; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										11
									
								
								packages/webshell/webshell-node/views/index.ejs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/webshell/webshell-node/views/index.ejs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="referrer" content="no-referrer" />  | ||||
|     <title>Web Shell</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="terminal"></div> | ||||
|   </body> | ||||
| </html> | ||||
		Reference in New Issue
	
	Block a user