bun-pty
Version:
Cross-platform pseudoterminal (PTY) implementation for Bun with native performance
246 lines (210 loc) • 7.47 kB
text/typescript
// terminal.ts — JS/TS front-end (final fixed version)
import { dlopen, FFIType, ptr } from "bun:ffi";
import { Buffer } from "node:buffer";
import { EventEmitter } from "./interfaces";
import type { IPty, IPtyForkOptions, IExitEvent } from "./interfaces";
import { join, dirname, basename } from "node:path";
import { existsSync } from "node:fs";
export const DEFAULT_COLS = 80;
export const DEFAULT_ROWS = 24;
export const DEFAULT_FILE = "sh";
export const DEFAULT_NAME = "xterm";
/**
* Quote a string for shell-words compatible splitting on the Rust side.
* We are not invoking a shell; quoting is only to preserve token boundaries
* when Rust parses the command line with shell_words::split.
*
* @param s - The string to quote
* @returns The quoted string
*/
function shQuote(s: string): string {
if (s.length === 0) return "''";
// Replace ' with '\'' (close-quote, escaped ', reopen)
return `'${s.replace(/'/g, `'\\''`)}'`;
}
// terminal.ts – loader fragment only
function resolveLibPath(): string {
const env = process.env.BUN_PTY_LIB;
if (env && existsSync(env)) return env;
// For bun compile: use statically analyzable require with inline ternary.
// Bun evaluates process.platform and process.arch at compile time and only
// bundles the file for the target platform. The ternary MUST be inline
// in the template literal for Bun's static analysis to work.
// See: https://github.com/sursaone/bun-pty/issues/19
try {
// @ts-ignore - require returns path for binary files in Bun
const embeddedPath = require(`../rust-pty/target/release/${process.platform === "win32" ? "rust_pty.dll" : process.platform === "darwin" ? (process.arch === "arm64" ? "librust_pty_arm64.dylib" : "librust_pty.dylib") : process.arch === "arm64" ? "librust_pty_arm64.so" : "librust_pty.so"}`);
if (embeddedPath) return embeddedPath;
} catch {
// Not running as compiled binary, fall through to dynamic resolution
}
// Fallback: dynamic resolution for development scenarios
const platform = process.platform;
const arch = process.arch;
// Try both architecture-specific and generic filenames
const filenames =
platform === "darwin"
? arch === "arm64"
? ["librust_pty_arm64.dylib", "librust_pty.dylib"]
: ["librust_pty.dylib"]
: platform === "win32"
? ["rust_pty.dll"]
: arch === "arm64"
? ["librust_pty_arm64.so", "librust_pty.so"]
: ["librust_pty.so"];
// Start from the current module's location
const base = Bun.fileURLToPath(import.meta.url);
const fileDir = dirname(base);
const dirName = basename(fileDir);
// Handle both development (src/terminal.ts) and production (dist/terminal.js) cases
// If we're in src/ or dist/, go up one level to get the project root
const here = (dirName === "src" || dirName === "dist")
? dirname(fileDir) // Go up one level from src/ or dist/
: fileDir; // Otherwise use the directory as-is
const basePaths = [
join(here, "rust-pty", "target", "release"), // Direct path from project root
join(here, "..", "bun-pty", "rust-pty", "target", "release"), // monorepo setups
join(process.cwd(), "node_modules", "bun-pty", "rust-pty", "target", "release"),
];
const fallbackPaths = [];
for (const basePath of basePaths) {
for (const filename of filenames) {
fallbackPaths.push(join(basePath, filename));
}
}
for (const path of fallbackPaths) {
if (existsSync(path)) return path;
}
throw new Error(
`librust_pty shared library not found.\nChecked:\n - BUN_PTY_LIB=${env ?? "<unset>"}\n - ${fallbackPaths.join("\n - ")}\n\nSet BUN_PTY_LIB or ensure one of these paths contains the file.`
);
}
const libPath = resolveLibPath();
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
let lib: any;
// try to load the lib, if it fails log the error
try {
lib = dlopen(libPath, {
bun_pty_spawn: {
args: [FFIType.cstring, FFIType.cstring, FFIType.cstring, FFIType.i32, FFIType.i32],
returns: FFIType.i32,
},
bun_pty_write: {
args: [FFIType.i32, FFIType.pointer, FFIType.i32],
returns: FFIType.i32,
},
bun_pty_read: {
args: [FFIType.i32, FFIType.pointer, FFIType.i32],
returns: FFIType.i32,
},
bun_pty_resize: {
args: [FFIType.i32, FFIType.i32, FFIType.i32],
returns: FFIType.i32,
},
bun_pty_kill: { args: [FFIType.i32], returns: FFIType.i32 },
bun_pty_get_pid: { args: [FFIType.i32], returns: FFIType.i32 },
bun_pty_get_exit_code: { args: [FFIType.i32], returns: FFIType.i32 },
bun_pty_close: { args: [FFIType.i32], returns: FFIType.void },
});
} catch (error) {
console.error("Failed to load lib", error);
}
export class Terminal implements IPty {
private handle = -1;
private _pid = -1;
private _cols = DEFAULT_COLS;
private _rows = DEFAULT_ROWS;
private readonly _name = DEFAULT_NAME;
private _readLoop = false;
private _closing = false;
private readonly _onData = new EventEmitter<string>();
private readonly _onExit = new EventEmitter<IExitEvent>();
constructor(
file = DEFAULT_FILE,
args: string[] = [],
opts: IPtyForkOptions = { name: DEFAULT_NAME },
) {
this._cols = opts.cols ?? DEFAULT_COLS;
this._rows = opts.rows ?? DEFAULT_ROWS;
const cwd = opts.cwd ?? process.cwd();
// Properly quote file and arguments to preserve spaces and special characters
const cmdline = [shQuote(file), ...args.map(shQuote)].join(" ");
// Format environment variables as null-terminated string
let envStr = "";
if (opts.env) {
const envPairs = Object.entries(opts.env).map(([k, v]) => `${k}=${v}`);
envStr = envPairs.join("\0") + "\0";
}
this.handle = lib.symbols.bun_pty_spawn(
Buffer.from(`${cmdline}\0`, "utf8"),
Buffer.from(`${cwd}\0`, "utf8"),
Buffer.from(`${envStr}\0`, "utf8"),
this._cols,
this._rows,
);
if (this.handle < 0) throw new Error("PTY spawn failed");
this._pid = lib.symbols.bun_pty_get_pid(this.handle);
this._startReadLoop();
}
/* ------------- accessors ------------- */
get pid() {
return this._pid;
}
get cols() {
return this._cols;
}
get rows() {
return this._rows;
}
get process() {
return "shell";
}
get onData() {
return this._onData.event;
}
get onExit() {
return this._onExit.event;
}
/* ------------- IO methods ------------- */
write(data: string) {
if (this._closing) return;
const buf = Buffer.from(data, "utf8");
lib.symbols.bun_pty_write(this.handle, ptr(buf), buf.length);
}
resize(cols: number, rows: number) {
if (this._closing) return;
this._cols = cols;
this._rows = rows;
lib.symbols.bun_pty_resize(this.handle, cols, rows);
}
kill(signal = "SIGTERM") {
if (this._closing) return;
this._closing = true;
lib.symbols.bun_pty_kill(this.handle);
lib.symbols.bun_pty_close(this.handle);
this._onExit.fire({ exitCode: 0, signal });
}
/* ------------- read-loop ------------- */
private async _startReadLoop() {
if (this._readLoop) return;
this._readLoop = true;
const buf = Buffer.allocUnsafe(4096);
while (this._readLoop && !this._closing) {
const n = lib.symbols.bun_pty_read(this.handle, ptr(buf), buf.length);
if (n > 0) {
this._onData.fire(buf.subarray(0, n).toString("utf8"));
} else if (n === -2) {
// CHILD_EXITED
const exitCode = lib.symbols.bun_pty_get_exit_code(this.handle);
this._onExit.fire({ exitCode });
break;
} else if (n < 0) {
// error
break;
} else {
// 0 bytes: wait
await new Promise((r) => setTimeout(r, 8));
}
}
}
}