Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement a new method of interacting with bit-server: node-pty #9247

Merged
merged 11 commits into from
Oct 16, 2024
4 changes: 2 additions & 2 deletions scopes/harmony/api-server/cli-raw.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class CLIRawRoute implements Route {

middlewares = [
async (req: Request, res: Response) => {
const { command, pwd, envBitFeatures, ttyPath } = req.body;
const { command, pwd, envBitFeatures, ttyPath, isPty } = req.body;
this.logger.debug(`cli-raw server: got request for ${command}`);
if (pwd && !pwd.startsWith(process.cwd())) {
throw new Error(`bit-server is running on a different directory. bit-server: ${process.cwd()}, pwd: ${pwd}`);
Expand All @@ -51,7 +51,7 @@ export class CLIRawRoute implements Route {
fs.writeSync(fileHandle, chunk.toString());
return originalStderrWrite.call(process.stdout, chunk, encoding, callback);
};
} else {
} else if (!isPty) {
process.env.BIT_CLI_SERVER_NO_TTY = 'true';
loader.shouldSendServerEvents = true;
}
Expand Down
5 changes: 4 additions & 1 deletion scopes/harmony/bit/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import { handleErrorAndExit } from '@teambit/cli';
import { runCLI } from './load-bit';
import { autocomplete } from './autocomplete';
import { ServerCommander, shouldUseBitServer } from './server-commander';
import { spawnPTY } from './server-forever';

if (process.argv.includes('--get-yargs-completions')) {
autocomplete();
process.exit(0);
}

if (shouldUseBitServer()) {
if (process.argv.includes('server-forever')) {
spawnPTY();
} else if (shouldUseBitServer()) {
new ServerCommander().execute().catch(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
initApp();
Expand Down
129 changes: 127 additions & 2 deletions scopes/harmony/bit/server-commander.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,47 @@
/**
* This file is responsible for interacting with bit through a long-running background process "bit-server" rather than directly.
* Why not directly?
* 1. startup cost. currently it takes around 1 second to bootstrap bit.
* 2. an experimental package-manager saves node_modules in-memory. if a client starts a new process, it won't have the node_modules in-memory.
*
* In this file, there are three ways to achieve this. It's outlined in the order it was evolved.
* The big challenge here is to show the output correctly to the client even though the server is running in a different process.
*
* 1. process.env.BIT_CLI_SERVER === 'true'
* This method uses SSE - Server Send Events. The server sends events to the client with the output to print. The client listens to
* these events and prints them. It's cumbersome. For this, the logger was changed and every time the logger needs to print to the console,
* it was using this SSE to send events. Same with the loader.
* Cons: Other output, such as pnpm, needs an extra effort to print - for pnpm, the "process" object was passed to pnpm
* and its stdout was modified to use the SSE.
* However, other tools that print directly to the console, such as Jest, won't work.
*
* 2. process.env.BIT_CLI_SERVER_TTY === 'true'
* Because the terminal - tty is a fd (file descriptor) on mac/linux, it can be passed to the server. The server can write to this
* fd and it will be printed to the client terminal. On the server, the process.stdout.write was monkey-patched to
* write to the tty. (see cli-raw.route.ts file).
* It solves the problem of Jest and other tools that print directly to the console.
* Cons:
* A. It doesn't work on Windows. Windows doesn't treat tty as a file descriptor.
* B. We need two ways communication. Commands such as "bit update", display a prompt with option to select using the arrow keys.
* This is not possible with the tty approach. Also, if the client hits Ctrl+C, the server won't know about it and it
* won't kill the process.
*
* 3. process.env.BIT_CLI_SERVER_PTY === 'true'
* This is the most advanced approach. It spawns a pty (pseudo terminal) process to communicate between the client and the server.
* The client connects to the server using a socket. The server writes to the socket and the client reads from it.
* The client also writes to the socket and the server reads from it. See server-forever.ts to understand better.
* In order to pass terminal sequences, such as arrow keys or Ctrl+C, the stdin of the client is set to raw mode.
* In theory, this approach could work by spawning a normal process, not pty, however, then, the stdin/stdout are non-tty,
* and as a result, loaders such as Ora and chalk won't work.
* With this new approach, we also support terminating and reloading the server. A new command is added
* "bit server-forever", which spawns the pty-process. If the client hits Ctrl+C, this server-forever process will kill
* the pty-process and re-load it.
* Keep in mind, that to send the command and get the results, we still using http. The usage of the pty is only for
* the input/output during the command.
* I was trying to avoid the http, and use only the pty, by implementing readline to get the command from the socket,
* but then I wasn't able to return the prompt to the user easily. So, I decided to keep the http for the request/response part.
*/

import fetch from 'node-fetch';
import net from 'net';
import fs from 'fs-extra';
Expand All @@ -8,6 +52,7 @@ import { findScopePath } from '@teambit/scope.modules.find-scope-path';
import chalk from 'chalk';
import loader from '@teambit/legacy/dist/cli/loader';
import { printBitVersionIfAsked } from './bootstrap';
import { getSocketPort } from './server-forever';

class ServerPortFileNotFound extends Error {
constructor(filePath: string) {
Expand Down Expand Up @@ -59,24 +104,30 @@ export class ServerCommander {

async runCommandWithHttpServer(): Promise<CommandResult | undefined> {
await this.printPortIfAsked();
this.printSocketPortIfAsked();
printBitVersionIfAsked();
const port = await this.getExistingUsedPort();
const url = `http://localhost:${port}/api`;
const shouldUsePTY = process.env.BIT_CLI_SERVER_PTY === 'true';

if (shouldUsePTY) {
await this.connectToSocket();
}
const ttyPath = this.shouldUseTTYPath()
? execSync('tty', {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'pipe'],
}).trim()
: undefined;
if (!ttyPath) this.initSSE(url);
if (!ttyPath && !shouldUsePTY) this.initSSE(url);
// parse the args and options from the command
const args = process.argv.slice(2);
if (!args.includes('--json') && !args.includes('-j')) {
loader.on();
}
const endpoint = `cli-raw`;
const pwd = process.cwd();
const body = { command: args, pwd, envBitFeatures: process.env.BIT_FEATURES, ttyPath };
const body = { command: args, pwd, envBitFeatures: process.env.BIT_FEATURES, ttyPath, isPty: shouldUsePTY };
let res;
try {
res = await fetch(`${url}/${endpoint}`, {
Expand Down Expand Up @@ -106,6 +157,67 @@ export class ServerCommander {
throw new Error(jsonResponse?.message || jsonResponse || res.statusText);
}

private async connectToSocket() {
return new Promise<void>((resolve, reject) => {
const socketPort = getSocketPort();
const socket = net.createConnection({ port: socketPort });

const resetStdin = () => {
process.stdin.setRawMode(false);
process.stdin.pause();
};

// Handle errors that occur before or after connection
socket.on('error', (err: any) => {
if (err.code === 'ECONNREFUSED') {
reject(
new Error(`Error: Unable to connect to the socket on port ${socketPort}.
Please run the command "bit server-forever" first to start the server.`)
);
}
resetStdin();
socket.destroy(); // Ensure the socket is fully closed
reject(err);
});

// Handle successful connection
socket.on('connect', () => {
process.stdin.setRawMode(true);
process.stdin.resume();

// Forward stdin to the socket
process.stdin.on('data', (data: any) => {
socket.write(data);

// Detect Ctrl+C (hex code '03')
if (data.toString('hex') === '03') {
// Important to write it to the socket so the server knows to kill the PTY process
process.stdin.setRawMode(false);
process.stdin.pause();
socket.end();
process.exit();
}
});

// Forward data from the socket to stdout
socket.on('data', (data: any) => {
process.stdout.write(data);
});

// Handle socket close and end events
const cleanup = () => {
resetStdin();
socket.destroy();
};

socket.on('close', cleanup);
socket.on('end', cleanup);

resolve(); // Connection successful, resolve the Promise
});
});
}

/**
* Initialize the server-sent events (SSE) connection to the server.
* This is used to print the logs and show the loader during the command.
Expand Down Expand Up @@ -152,6 +264,18 @@ export class ServerCommander {
}
}

private printSocketPortIfAsked() {
if (!process.argv.includes('cli-server-socket-port')) return;
try {
const port = getSocketPort();
process.stdout.write(port.toString());
process.exit(0);
} catch (err: any) {
console.error(err.message); // eslint-disable-line no-console
process.exit(1);
}
}

private async getExistingUsedPort(): Promise<number> {
const port = await this.getExistingPort();
const isPortInUse = await this.isPortInUse(port);
Expand Down Expand Up @@ -216,6 +340,7 @@ export function shouldUseBitServer() {
const hasFlag =
process.env.BIT_CLI_SERVER === 'true' ||
process.env.BIT_CLI_SERVER === '1' ||
process.env.BIT_CLI_SERVER_PTY === 'true' ||
process.env.BIT_CLI_SERVER_TTY === 'true';
return (
hasFlag &&
Expand Down
123 changes: 123 additions & 0 deletions scopes/harmony/bit/server-forever.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* see the docs of server-commander.ts for more info
* this "server-forever" command is used to run the bit server in a way that it will never stop. if it gets killed,
* it will restart itself.
* it spawns "bit server" using node-pty for a pseudo-terminal (PTY) in order for libs such as inquirer/ora/chalk to work properly.
*/

/* eslint-disable no-console */

import net from 'net';
import crypto from 'crypto';
import { spawn } from 'node-pty';

export function spawnPTY() {
// Create a PTY (terminal emulation) running the 'bit server' process
// this way, we can catch terminal sequences like arrows, ctrl+c, etc.
const ptyProcess = spawn('bit', ['server', '--pty'], {
name: 'xterm-color',
cols: 80,
rows: 30,
cwd: process.cwd(),
env: process.env,
});

// Create a TCP server
const server = net.createServer((socket) => {
console.log('Client connected.');

// Forward data from the client to the ptyProcess
socket.on('data', (data: any) => {
// console.log('Server received data from client:', data.toString());
if (data.toString('hex') === '03') {
// User hit ctrl+c
ptyProcess.kill();
} else {
ptyProcess.write(data);
}
});

// Forward data from the ptyProcess to the client
// @ts-ignore
ptyProcess.on('data', (data) => {
// console.log('ptyProcess data:', data.toString());
socket.write(data);
});

// Handle client disconnect
socket.on('end', (item) => {
console.log('Client disconnected.', item);
});

// Handle errors
socket.on('error', (err) => {
console.error('Socket error:', err);
});

// @ts-ignore
ptyProcess.on('exit', (code, signal) => {
console.log(`PTY exited with code ${code} and signal ${signal}`);
server.close();
setTimeout(() => {
console.log('restarting the server');
spawnPTY();
}, 500);
});

// @ts-ignore
ptyProcess.on('error', (err) => {
console.error('PTY process error:', err);
});
});

const PORT = getSocketPort();

server.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
console.error(`Error: Port ${PORT} is already in use.`);
console.error(`This port is assigned based on the workspace path: '${process.cwd()}'`);
console.error(`This means another instance may already be running in this workspace.`);
console.error(`\nTo resolve this issue:`);
console.error(`- If another instance is running, please stop it before starting a new one.`);
console.error(`- If no other instance is running, the port may be occupied by another application.`);
console.error(
` You can override the default port by setting the 'BIT_CLI_SERVER_SOCKET_PORT' environment variable.`
);
process.exit(1); // Exit the process with an error code
} else {
console.error('Server encountered an error:', err);
process.exit(1);
}
});

server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
}

export function getSocketPort(): number {
return process.env.BIT_CLI_SERVER_SOCKET_PORT
? parseInt(process.env.BIT_CLI_SERVER_SOCKET_PORT)
: getPortFromPath(process.cwd());
}

/**
* it's easier to generate a random port based on the workspace path than to save it in a file
*/
export function getPortFromPath(path: string): number {
// Step 1: Hash the workspace path using MD5
const hash = crypto.createHash('md5').update(path).digest('hex');

// Step 2: Convert a portion of the hash to an integer
// We'll use the first 8 characters (32 bits)
const hashInt = parseInt(hash.substring(0, 8), 16);

// Step 3: Map the integer to the port range 49152 to 65535 (these are dynamic ports not assigned by IANA)
const minPort = 49152;
const maxPort = 65535;
const portRange = maxPort - minPort + 1;

const port = (hashInt % portRange) + minPort;

return port;
}
1 change: 1 addition & 0 deletions workspace.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@
"mousetrap": "1.6.5",
"multimatch": "5.0.0",
"nerf-dart": "1.0.0",
"node-pty": "^1.0.0",
"npm": "6.14.17",
"p-limit": "3.1.0",
"p-locate": "5.0.0",
Expand Down