diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..e623fd6 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,201 @@ +name: CI +env: + DEBUG: napi:* + APP_NAME: ruspty + MACOSX_DEPLOYMENT_TARGET: '10.13' + +permissions: + contents: write + id-token: write +'on': + push: + branches: + - main + tags-ignore: + - '**' + paths-ignore: + - '**/*.md' + - LICENSE + - '**/*.gitignore' + - .editorconfig + - docs/** + pull_request: null + +jobs: + build: + strategy: + fail-fast: false + matrix: + settings: + - host: macos-latest + target: x86_64-apple-darwin + build: | + bun run build + strip -x *.node + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian + build: |- + set -e && + npm install -g bun && + bun run build --target x86_64-unknown-linux-gnu && + strip *.node + name: Build on ${{ matrix.settings.target }} + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + - name: Setup node + uses: actions/setup-node@v4 + if: ${{ !matrix.settings.docker }} + with: + node-version: 20 + - name: Install + uses: dtolnay/rust-toolchain@stable + if: ${{ !matrix.settings.docker }} + with: + toolchain: stable + targets: ${{ matrix.settings.target }} + - name: Cache cargo + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + .cargo-cache + target/ + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }} + - uses: goto-bus-stop/setup-zig@v2 + if: ${{ matrix.settings.target == 'armv7-unknown-linux-gnueabihf' }} + with: + version: 0.11.0 + - name: Setup toolchain + run: ${{ matrix.settings.setup }} + if: ${{ matrix.settings.setup }} + shell: bash + - name: Install dependencies + run: bun install + - name: Build in docker + uses: addnab/docker-run-action@v3 + if: ${{ matrix.settings.docker }} + with: + image: ${{ matrix.settings.docker }} + options: '--user 0:0 -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db -v ${{ github.workspace }}/.cargo/registry/cache:/usr/local/cargo/registry/cache -v ${{ github.workspace }}/.cargo/registry/index:/usr/local/cargo/registry/index -v ${{ github.workspace }}:/build -w /build' + run: ${{ matrix.settings.build }} + - name: Build + run: ${{ matrix.settings.build }} + if: ${{ !matrix.settings.docker }} + shell: bash + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: bindings-${{ matrix.settings.target }} + path: ${{ env.APP_NAME }}.*.node + if-no-files-found: error + + test-macos-binding: + name: Test on ${{ matrix.settings.target }} + needs: + - build + strategy: + matrix: + settings: + - host: macos-latest + target: x86_64-apple-darwin + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install dependencies + run: bun install + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: bindings-${{ matrix.settings.target }} + path: . + - name: List packages + run: ls -R . + shell: bash + - name: Test bindings + run: bun test + + test-linux-binding: + name: Test on ${{ matrix.settings.target }} + needs: + - build + strategy: + matrix: + settings: + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install dependencies + run: bun install + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: bindings-${{ matrix.settings.target }} + path: . + - name: List packages + run: ls -R . + shell: bash + - name: Test bindings + run: docker run --rm -v $(pwd):/build -w /build oven/bun:1 bun test + + publish: + name: Publish + runs-on: ubuntu-latest + needs: + - test-macos-binding + - test-linux-binding + steps: + - uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install dependencies + run: bun install + - name: Download all artifacts + uses: actions/download-artifact@v3 + with: + path: artifacts + - name: Move artifacts + run: bun run artifacts + - name: List packages + run: ls -R ./npm + shell: bash + - name: Publish + run: | + npm config set provenance true + if git log -1 --pretty=%B | grep "^[0-9]\+\.[0-9]\+\.[0-9]\+$"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --access public + elif git log -1 --pretty=%B | grep "^[0-9]\+\.[0-9]\+\.[0-9]\+"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --tag next --access public + else + echo "Not a release, skipping publish" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00ed0f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,198 @@ +# Created by https://www.toptal.com/developers/gitignore/api/node +# Edit at https://www.toptal.com/developers/gitignore?templates=node + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# End of https://www.toptal.com/developers/gitignore/api/node + +# Created by https://www.toptal.com/developers/gitignore/api/macos +# Edit at https://www.toptal.com/developers/gitignore?templates=macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +# End of https://www.toptal.com/developers/gitignore/api/macos + +# Created by https://www.toptal.com/developers/gitignore/api/windows +# Edit at https://www.toptal.com/developers/gitignore?templates=windows + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/windows + +#Added by cargo + +/target +Cargo.lock +.cargo/ + +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +*.node diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..ec144db --- /dev/null +++ b/.npmignore @@ -0,0 +1,13 @@ +target +Cargo.lock +.cargo +.github +npm +.eslintrc +.prettierignore +rustfmt.toml +yarn.lock +*.node +.yarn +__test__ +renovate.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..cbbec35 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "quoteProps": "as-needed", + "trailingComma": "all", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "bracketSpacing": true, + "useTabs": false, + "arrowParens": "always" +} diff --git a/.replit b/.replit new file mode 100644 index 0000000..7ce2db0 --- /dev/null +++ b/.replit @@ -0,0 +1,17 @@ +run = "bun run build:debug && bun test" +modules = ["bun-1.0:v17-20240117-0bd73cd", "nodejs-20:v24-20240117-0bd73cd", "rust-stable:v4-20240117-0bd73cd"] + +disableGuessImports = true +disableInstallBeforeRun = true + +[nix] +channel = "stable-23_11" + +[rules] + +[rules.formatter] + +[rules.formatter.fileExtensions] + +[rules.formatter.fileExtensions.".ts"] +id = "module:nodejs-20:v24-20240117-0bd73cd/formatter:prettier" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0c33883 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition = "2021" +name = "replit_ruspty" +version = "1.0.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix +napi = { version = "2.12.2", default-features = false, features = ["napi4"] } +napi-derive = "2.12.2" +rustix = "0.38.30" +rustix-openpty = "0.1.1" +libc = "0.2.152" + +[build-dependencies] +napi-build = "2.0.1" + +[profile.release] +lto = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..60bca42 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# `@replit/ruspty` - PTY for Bun (and Node) through Rust FFI + +Running: + +- `bun install` +- `bun run build` +- `bun test` + +The code mainly targets Bun, but does work in Node too. + +The biggest difference from existing PTY libraries is that this one works with Bun, and doesn't cross the FFI bridge for every input/output instead requiring the consumer to deal with the `fd` of the PTY. + +The Rust PTY implementation is cargo-culted from [Alacritty's Unix TTY code](https://github.com/alacritty/alacritty/blob/master/alacritty_terminal/src/tty/unix.rs). + + +## Publishing + +Following ["Publish It" section from `napi-rs` docs](https://napi.rs/docs/introduction/simple-package#publish-it): + +- `npm version [major|minor|patch]` +- `git push --follow tags` + +Github Action should take care of publishing after that. + +`NPM_TOKEN` is part of the repo secrets, generated [like this](https://httptoolkit.com/blog/automatic-npm-publish-gha/). + diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..1f866b6 --- /dev/null +++ b/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..50a1bf2 Binary files /dev/null and b/bun.lockb differ diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..12f8396 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,15 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export interface Size { + cols: number + rows: number +} +export class Pty { + fd: number + pid: number + constructor(command: string, args: Array, envs: Record, dir: string, size: Size, onExit: (err: null | Error, exitCode: number) => void) + resize(size: Size): void +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..1481fc1 --- /dev/null +++ b/index.js @@ -0,0 +1,300 @@ +/* tslint:disable */ +/* eslint-disable */ +/* prettier-ignore */ + +/* auto-generated by NAPI-RS */ + +const { existsSync, readFileSync } = require('fs') +const { join } = require('path') + +const { platform, arch } = process + +let nativeBinding = null +let localFileExisted = false +let loadError = null + +function isMusl() { + // For Node 10 + if (!process.report || typeof process.report.getReport !== 'function') { + try { + const lddPath = require('child_process').execSync('which ldd').toString().trim() + return readFileSync(lddPath, 'utf8').includes('musl') + } catch (e) { + return true + } + } else { + const { glibcVersionRuntime } = process.report.getReport().header + return !glibcVersionRuntime + } +} + +switch (platform) { + case 'android': + switch (arch) { + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'ruspty.android-arm64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.android-arm64.node') + } else { + nativeBinding = require('@replit/ruspty-android-arm64') + } + } catch (e) { + loadError = e + } + break + case 'arm': + localFileExisted = existsSync(join(__dirname, 'ruspty.android-arm-eabi.node')) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.android-arm-eabi.node') + } else { + nativeBinding = require('@replit/ruspty-android-arm-eabi') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Android ${arch}`) + } + break + case 'win32': + switch (arch) { + case 'x64': + localFileExisted = existsSync( + join(__dirname, 'ruspty.win32-x64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.win32-x64-msvc.node') + } else { + nativeBinding = require('@replit/ruspty-win32-x64-msvc') + } + } catch (e) { + loadError = e + } + break + case 'ia32': + localFileExisted = existsSync( + join(__dirname, 'ruspty.win32-ia32-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.win32-ia32-msvc.node') + } else { + nativeBinding = require('@replit/ruspty-win32-ia32-msvc') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'ruspty.win32-arm64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.win32-arm64-msvc.node') + } else { + nativeBinding = require('@replit/ruspty-win32-arm64-msvc') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Windows: ${arch}`) + } + break + case 'darwin': + localFileExisted = existsSync(join(__dirname, 'ruspty.darwin-universal.node')) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.darwin-universal.node') + } else { + nativeBinding = require('@replit/ruspty-darwin-universal') + } + break + } catch {} + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'ruspty.darwin-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.darwin-x64.node') + } else { + nativeBinding = require('@replit/ruspty-darwin-x64') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'ruspty.darwin-arm64.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.darwin-arm64.node') + } else { + nativeBinding = require('@replit/ruspty-darwin-arm64') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on macOS: ${arch}`) + } + break + case 'freebsd': + if (arch !== 'x64') { + throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) + } + localFileExisted = existsSync(join(__dirname, 'ruspty.freebsd-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.freebsd-x64.node') + } else { + nativeBinding = require('@replit/ruspty-freebsd-x64') + } + } catch (e) { + loadError = e + } + break + case 'linux': + switch (arch) { + case 'x64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-x64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-x64-musl.node') + } else { + nativeBinding = require('@replit/ruspty-linux-x64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-x64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-x64-gnu.node') + } else { + nativeBinding = require('@replit/ruspty-linux-x64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-arm64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-arm64-musl.node') + } else { + nativeBinding = require('@replit/ruspty-linux-arm64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-arm64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-arm64-gnu.node') + } else { + nativeBinding = require('@replit/ruspty-linux-arm64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm': + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-arm-gnueabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-arm-gnueabihf.node') + } else { + nativeBinding = require('@replit/ruspty-linux-arm-gnueabihf') + } + } catch (e) { + loadError = e + } + break + case 'riscv64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-riscv64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-riscv64-musl.node') + } else { + nativeBinding = require('@replit/ruspty-linux-riscv64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-riscv64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-riscv64-gnu.node') + } else { + nativeBinding = require('@replit/ruspty-linux-riscv64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 's390x': + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-s390x-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-s390x-gnu.node') + } else { + nativeBinding = require('@replit/ruspty-linux-s390x-gnu') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Linux: ${arch}`) + } + break + default: + throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) +} + +if (!nativeBinding) { + if (loadError) { + throw loadError + } + throw new Error(`Failed to load native binding`) +} + +const { Pty } = nativeBinding + +module.exports.Pty = Pty diff --git a/index.test.ts b/index.test.ts new file mode 100644 index 0000000..6ad32e0 --- /dev/null +++ b/index.test.ts @@ -0,0 +1,195 @@ +import fs from 'fs'; +import { Pty } from './index'; + +describe('PTY', () => { + const CWD = process.cwd(); + + test('spawns and exits', (done) => { + const message = 'hello from a pty'; + + const pty = new Pty( + '/bin/echo', + [message], + {}, + CWD, + { rows: 24, cols: 80 }, + (err, exitCode) => { + expect(err).toBeNull(); + expect(exitCode).toBe(0); + done(); + }, + ); + + const readStream = fs.createReadStream('', { fd: pty.fd }); + + readStream.on('data', (chunk) => { + expect(chunk.toString()).toBe(message + '\r\n'); + }); + }); + + test('captures an exit code', (done) => { + new Pty( + '/bin/sh', + ['-c', 'exit 17'], + {}, + CWD, + { rows: 24, cols: 80 }, + (err, exitCode) => { + expect(err).toBeNull(); + expect(exitCode).toBe(17); + done(); + }, + ); + }); + + test('can be written to', (done) => { + const message = 'hello cat'; + + const pty = new Pty( + '/bin/cat', + [], + {}, + CWD, + { rows: 24, cols: 80 }, + () => {}, + ); + + const readStream = fs.createReadStream('', { fd: pty.fd }); + const writeStream = fs.createWriteStream('', { fd: pty.fd }); + + readStream.on('data', (chunk) => { + expect(chunk.toString()).toBe(message); + done(); + }); + + writeStream.write(message); + }); + + test('can be resized', (done) => { + const pty = new Pty( + '/bin/sh', + [], + {}, + CWD, + { rows: 24, cols: 80 }, + () => {}, + ); + + const readStream = fs.createReadStream('', { fd: pty.fd }); + const writeStream = fs.createWriteStream('', { fd: pty.fd }); + + let buffer = ''; + + readStream.on('data', (chunk) => { + buffer += chunk.toString(); + + if (buffer.includes('done1\r\n')) { + expect(buffer).toContain('24 80'); + pty.resize({ rows: 60, cols: 100 }); + buffer = ''; + writeStream.write("stty size; echo 'done2'\n"); + } + + if (buffer.includes('done2\r\n')) { + expect(buffer).toContain('60 100'); + done(); + } + }); + + writeStream.write("stty size; echo 'done1'\n"); + }); + + test('respects working directory', (done) => { + const pty = new Pty( + '/bin/pwd', + [], + {}, + CWD, + { rows: 24, cols: 80 }, + (err, exitCode) => { + expect(err).toBeNull(); + expect(exitCode).toBe(0); + done(); + }, + ); + + const readStream = fs.createReadStream('', { fd: pty.fd }); + + readStream.on('data', (chunk) => { + expect(chunk.toString()).toBe(`${CWD}\r\n`); + }); + }); + + test.skip('respects env', (done) => { + const message = 'hello from env'; + let buffer = ''; + + const pty = new Pty( + '/bin/sh', + ['-c', 'sleep 0.1s && echo $ENV_VARIABLE && exit'], + { + ENV_VARIABLE: message, + }, + CWD, + { rows: 24, cols: 80 }, + (err, exitCode) => { + expect(err).toBeNull(); + expect(exitCode).toBe(0); + expect(buffer).toBe(message + '\r\n'); + + done(); + }, + ); + + const readStream = fs.createReadStream('', { fd: pty.fd }); + + readStream.on('data', (chunk) => { + buffer += chunk.toString(); + }); + }); + + test('works with Bun.read & Bun.write', (done) => { + const message = 'hello bun'; + + const pty = new Pty( + '/bin/cat', + [], + {}, + CWD, + { rows: 24, cols: 80 }, + () => {}, + ); + + const file = Bun.file(pty.fd); + + async function read() { + const stream = file.stream(); + + for await (const chunk of stream) { + expect(Buffer.from(chunk).toString()).toBe(message); + done(); + } + } + + read(); + + Bun.write(pty.fd, message); + }); + + test("doesn't break when executing non-existing binary", (done) => { + try { + new Pty( + '/bin/this-does-not-exist', + [], + {}, + CWD, + { rows: 24, cols: 80 }, + () => {}, + ); + } catch (e) { + expect(e.message).toContain('No such file or directory'); + + done(); + } + }); +}); diff --git a/npm/darwin-x64/README.md b/npm/darwin-x64/README.md new file mode 100644 index 0000000..0d9fbba --- /dev/null +++ b/npm/darwin-x64/README.md @@ -0,0 +1,3 @@ +# `@replit/ruspty-darwin-x64` + +This is the **x86_64-apple-darwin** binary for `@replit/ruspty` diff --git a/npm/darwin-x64/package.json b/npm/darwin-x64/package.json new file mode 100644 index 0000000..799fafd --- /dev/null +++ b/npm/darwin-x64/package.json @@ -0,0 +1,18 @@ +{ + "name": "@replit/ruspty-darwin-x64", + "version": "1.0.0", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "main": "ruspty.darwin-x64.node", + "files": [ + "ruspty.darwin-x64.node" + ], + "license": "MIT", + "engines": { + "node": ">= 10" + } +} \ No newline at end of file diff --git a/npm/linux-x64-gnu/README.md b/npm/linux-x64-gnu/README.md new file mode 100644 index 0000000..fa4c1fc --- /dev/null +++ b/npm/linux-x64-gnu/README.md @@ -0,0 +1,3 @@ +# `@replit/ruspty-linux-x64-gnu` + +This is the **x86_64-unknown-linux-gnu** binary for `@replit/ruspty` diff --git a/npm/linux-x64-gnu/package.json b/npm/linux-x64-gnu/package.json new file mode 100644 index 0000000..56b05e6 --- /dev/null +++ b/npm/linux-x64-gnu/package.json @@ -0,0 +1,21 @@ +{ + "name": "@replit/ruspty-linux-x64-gnu", + "version": "1.0.0", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "main": "ruspty.linux-x64-gnu.node", + "files": [ + "ruspty.linux-x64-gnu.node" + ], + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "libc": [ + "glibc" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..691db7e --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@replit/ruspty", + "version": "1.0.0", + "main": "index.js", + "types": "index.d.ts", + "author": "Szymon Kaliski ", + "napi": { + "name": "ruspty", + "triples": { + "defaults": false, + "additional": [ + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu" + ] + } + }, + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^2.17.0", + "@types/node": "^20.4.1", + "@types/jest": "^29.5.11", + "prettier": "^3.2.4" + }, + "scripts": { + "artifacts": "napi artifacts", + "build": "napi build --platform --release", + "build:debug": "napi build --platform", + "prepublishOnly": "napi prepublish -t npm", + "test": "bun test", + "universal": "napi universal", + "version": "napi version", + "release": "npm publish --access public", + "format": "npx prettier *.ts --write" + }, + "optionalDependencies": { + "@replit/ruspty-darwin-x64": "1.0.0-alpha.1", + "@replit/ruspty-linux-x64-gnu": "1.0.0-alpha.1" + } +} diff --git a/replit.nix b/replit.nix new file mode 100644 index 0000000..66babba --- /dev/null +++ b/replit.nix @@ -0,0 +1,3 @@ +{ pkgs }: { + deps = []; +} \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..cab5731 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +tab_spaces = 2 +edition = "2021" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a7ff449 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,223 @@ +use libc::{self, c_int, TIOCSCTTY}; +use napi::bindgen_prelude::JsFunction; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}; +use napi::Error as NAPI_ERROR; +use napi::Status::GenericFailure; +use rustix_openpty::openpty; +use rustix_openpty::rustix::termios::Winsize; +use rustix_openpty::rustix::termios::{self, InputModes, OptionalActions}; +use std::collections::HashMap; +use std::fs::File; +use std::io::Error; +use std::io::ErrorKind; +use std::os::fd::AsRawFd; +use std::os::fd::FromRawFd; +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; +use std::thread; + +#[macro_use] +extern crate napi_derive; + +#[napi] +#[allow(dead_code)] +struct Pty { + file: File, + #[napi(ts_type = "number")] + pub fd: c_int, + pub pid: u32, +} + +#[napi(object)] +struct Size { + pub cols: u16, + pub rows: u16, +} + +#[allow(dead_code)] +fn set_controlling_terminal(fd: c_int) -> Result<(), Error> { + let res = unsafe { + #[allow(clippy::cast_lossless)] + libc::ioctl(fd, TIOCSCTTY as _, 0) + }; + + if res != 0 { + return Err(Error::last_os_error()); + } + + Ok(()) +} + +#[allow(dead_code)] +fn set_nonblocking(fd: c_int) -> Result<(), NAPI_ERROR> { + use libc::{fcntl, F_GETFL, F_SETFL, O_NONBLOCK}; + + let status_flags = unsafe { fcntl(fd, F_GETFL, 0) }; + + if status_flags < 0 { + return Err(NAPI_ERROR::new( + napi::Status::GenericFailure, + format!("fcntl F_GETFL failed: {}", Error::last_os_error()), + )); + } + + let res = unsafe { fcntl(fd, F_SETFL, status_flags | O_NONBLOCK) }; + + if res != 0 { + return Err(NAPI_ERROR::new( + napi::Status::GenericFailure, + format!("fcntl F_SETFL failed: {}", Error::last_os_error()), + )); + } + + Ok(()) +} + +#[napi] +impl Pty { + #[napi(constructor)] + #[allow(dead_code)] + pub fn new( + command: String, + args: Vec, + envs: HashMap, + dir: String, + size: Size, + #[napi(ts_arg_type = "(err: null | Error, exitCode: number) => void")] on_exit: JsFunction, + ) -> Result { + let window_size = Winsize { + ws_col: size.cols, + ws_row: size.rows, + ws_xpixel: 0, + ws_ypixel: 0, + }; + + let mut cmd = Command::new(command); + cmd.args(args); + + let pty_pair = openpty(None, Some(&window_size)) + .map_err(|err| NAPI_ERROR::new(napi::Status::GenericFailure, err))?; + + let fd_controller = pty_pair.controller.as_raw_fd(); + let fd_user = pty_pair.user.as_raw_fd(); + + if let Ok(mut termios) = termios::tcgetattr(&pty_pair.controller) { + termios.input_modes.set(InputModes::IUTF8, true); + termios::tcsetattr(&pty_pair.controller, OptionalActions::Now, &termios) + .map_err(|err| NAPI_ERROR::new(napi::Status::GenericFailure, err))?; + } + + cmd.stdin(unsafe { Stdio::from_raw_fd(fd_user) }); + cmd.stderr(unsafe { Stdio::from_raw_fd(fd_user) }); + cmd.stdout(unsafe { Stdio::from_raw_fd(fd_user) }); + + cmd.envs(envs); + cmd.current_dir(dir); + + unsafe { + cmd.pre_exec(move || { + let err = libc::setsid(); + if err == -1 { + return Err(Error::new(ErrorKind::Other, "Failed to set session id")); + } + + set_controlling_terminal(fd_user)?; + + libc::close(fd_user); + libc::close(fd_controller); + + libc::signal(libc::SIGCHLD, libc::SIG_DFL); + libc::signal(libc::SIGHUP, libc::SIG_DFL); + libc::signal(libc::SIGINT, libc::SIG_DFL); + libc::signal(libc::SIGQUIT, libc::SIG_DFL); + libc::signal(libc::SIGTERM, libc::SIG_DFL); + libc::signal(libc::SIGALRM, libc::SIG_DFL); + + Ok(()) + }); + } + + let ts_on_exit: ThreadsafeFunction = on_exit + .create_threadsafe_function(0, |ctx| ctx.env.create_int32(ctx.value).map(|v| vec![v]))?; + + let mut child = cmd + .spawn() + .map_err(|err| NAPI_ERROR::new(GenericFailure, err))?; + + let pid = child.id(); + + set_nonblocking(fd_controller)?; + + let file = File::from(pty_pair.controller); + let fd = file.as_raw_fd(); + + // We're creating a new thread for every child, this uses a bit more system resources compared + // to alternatives (below), trading off simplicity of implementation. + // + // The alternatives: + // - Mandate that every single `wait` goes through a central process-wide loop that knows + // about all processes (this is what `pid1` does), but needs a bit of care and some static + // analysis to ensure that every single call goes through the wrapper to avoid double `wait`'s + // on a child. + // - Have a single thread loop where other entities can register children (by sending the pid + // over a channel) and this loop can use `epoll` to listen for each child's `pidfd` for when + // they are ready to be `wait`'ed. This has the inconvenience that it consumes one FD per child. + // + // For discussion check out: https://github.com/replit/ruspty/pull/1#discussion_r1463672548 + thread::spawn(move || { + match child.wait() { + Ok(status) => { + if status.success() { + ts_on_exit.call(Ok(0), ThreadsafeFunctionCallMode::Blocking); + } else { + ts_on_exit.call( + Ok(status.code().unwrap_or(-1)), + ThreadsafeFunctionCallMode::Blocking, + ); + } + } + Err(err) => { + ts_on_exit.call( + Err(NAPI_ERROR::new( + GenericFailure, + format!( + "OS error when waiting for child process to exit: {}", + err.raw_os_error().unwrap_or(-1) + ), + )), + ThreadsafeFunctionCallMode::Blocking, + ); + } + } + + // Close the fd once we return from `child.wait()`. + unsafe { + rustix::io::close(fd); + } + }); + + Ok(Pty { file, fd, pid }) + } + + #[napi] + #[allow(dead_code)] + pub fn resize(&mut self, size: Size) -> Result<(), NAPI_ERROR> { + let window_size = Winsize { + ws_col: size.cols, + ws_row: size.rows, + ws_xpixel: 0, + ws_ypixel: 0, + }; + + let res = unsafe { libc::ioctl(self.fd, libc::TIOCSWINSZ, &window_size as *const _) }; + + if res != 0 { + return Err(NAPI_ERROR::new( + napi::Status::GenericFailure, + format!("ioctl TIOCSWINSZ failed: {}", Error::last_os_error()), + )); + } + + Ok(()) + } +}