Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/bashkit-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ authors.workspace = true
repository.workspace = true
publish = false

[features]
# Interactive `Terminal` class (keystrokes in, escape-sequence bytes out) for
# xterm.js-style renderers. Off by default for crate users, like
# `bashkit/terminal`; `scripts/build.sh` turns it on for the npm package because
# it costs ~60 KB gzipped and stays inert until a page constructs `Terminal`.
terminal = ["bashkit/terminal"]

[lib]
crate-type = ["cdylib", "rlib"]

Expand Down
40 changes: 40 additions & 0 deletions crates/bashkit-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,46 @@ as the `Bash` VFS helpers below.
- `executeWithOutput(cmd, callback)`, async execution plus incremental
`(stdout, stderr)` chunks. The returned `ExecResult` remains authoritative.

## Interactive terminal

`Terminal` runs an interactive shell session on an in-memory terminal: you
forward keystrokes, it returns escape-sequence bytes for a renderer such as
[xterm.js](https://xtermjs.org). `vi`, `less` and `more` run full-screen. Try it
on the [bashkit.sh playground](https://bashkit.sh/playground).

```js
import { Terminal as XTerm } from "@xterm/xterm";
import { initBashkit, Terminal } from "@everruns/bashkit-wasm";

await initBashkit();
const xterm = new XTerm();
xterm.open(document.getElementById("term"));

const term = new Terminal({ rows: xterm.rows, cols: xterm.cols, cwd: "/home/user" });
let running = false;
let again = false;
async function pump() {
if (running) return void (again = true);
running = true;
do {
again = false;
await term.runUntilIdle(); // { status: "idle" } or { status: "exited", exitCode }
xterm.write(term.takeOutput());
} while (again);
running = false;
}
xterm.onData((keys) => { term.send(keys); pump(); });
xterm.onResize(({ rows, cols }) => term.resize(rows, cols));
pump();
```

`runUntilIdle()` resolves once the session waits for input; keystrokes sent
while a command runs are queued. `screenText()` returns the visible screen as
plain text and `fs()` reads what `vi` saved. `Terminal` takes every `Bash`
option plus `rows` and `cols`. It is `undefined` in custom builds made with
`BASHKIT_WASM_FEATURES=""`. See the [terminal guide](https://bashkit.sh/docs/terminal/)
for the line editor, `vi` keys and limits.

## Options

```ts
Expand Down
64 changes: 64 additions & 0 deletions crates/bashkit-wasm/__test__/terminal.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Terminal (xterm.js-style) session tests. The bundle exposes `Terminal` only
// when built with the `terminal` cargo feature, which scripts/build.sh enables
// by default. With BASHKIT_WASM_FEATURES="" `Terminal` is undefined and these
// tests skip.

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

import { initBashkit, Terminal } from "../pkg/index.js";

await initBashkit(
readFileSync(fileURLToPath(new URL("../pkg/bashkit_wasm_bg.wasm", import.meta.url))),
);

const skip = Terminal === undefined && "built without the terminal feature";
const decoder = new TextDecoder();

test("terminal: prompt, echo and output bytes", { skip }, async () => {
const term = new Terminal({ rows: 10, cols: 40 });
assert.deepEqual(await term.runUntilIdle(), { status: "idle" });
term.send("echo hi | tr a-z A-Z\r");
assert.deepEqual(await term.runUntilIdle(), { status: "idle" });
assert.equal(term.screenText(), "$ echo hi | tr a-z A-Z\nHI\n$");
const out = decoder.decode(term.takeOutput());
assert.match(out, /HI\r\n/);
assert.equal(term.takeOutput().length, 0, "output is drained");
});

test("terminal: vi edits a VFS file", { skip }, async () => {
const term = new Terminal();
term.send("vi /tmp/n.txt\r");
await term.runUntilIdle();
assert.equal(term.isAlternateScreen(), true);
term.send("ihello\x1b:wq\r");
await term.runUntilIdle();
assert.equal(term.isAlternateScreen(), false);
assert.equal(term.fs().readFile("/tmp/n.txt"), "hello\n");
});

test("terminal: options, resize and exit", { skip }, async () => {
const term = new Terminal({ cols: 50, files: { "/data/a.txt": "seeded\n" } });
term.resize(12, 60);
assert.equal(term.rows, 12);
assert.equal(term.cols, 60);
term.send("cat /data/a.txt; echo $COLUMNS\r");
await term.runUntilIdle();
assert.match(term.screenText(), /seeded\n60/);
term.send("exit 4\r");
assert.deepEqual(await term.runUntilIdle(), { status: "exited", exitCode: 4 });
assert.equal(term.exitCode, 4);
});

test("terminal: keystrokes sent mid-command are queued", { skip }, async () => {
const term = new Terminal();
await term.runUntilIdle();
term.send("sleep 0.05; echo done\r");
const running = term.runUntilIdle();
term.send("echo next\r");
await running;
await term.runUntilIdle();
assert.match(term.screenText(), /done\n\$ echo next\nnext\n\$$/);
});
39 changes: 39 additions & 0 deletions crates/bashkit-wasm/js/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,45 @@ export class Bash {
ls(path: string): string[];
}

/** Options for constructing a {@link Terminal}. */
export interface TerminalOptions extends BashOptions {
/** Rows (default 24, min 2). */
rows?: number;
/** Columns (default 80, min 2). */
cols?: number;
}

/** Result of {@link Terminal.runUntilIdle}. */
export type TerminalStatus =
| { status: "idle" }
| { status: "exited"; exitCode: number };

/**
* Interactive bash session on an in-memory terminal: keystrokes in,
* escape-sequence bytes out, for renderers such as xterm.js. Runs `vi`,
* `less` and `more` full-screen.
*/
export class Terminal {
constructor(options?: TerminalOptions);
/** Queue keystrokes (`\r` Enter, `\x03` Ctrl-C). Returns bytes accepted. */
send(input: string): number;
sendBytes(input: Uint8Array): number;
/** Run until the session waits for input or the shell exits. */
runUntilIdle(): Promise<TerminalStatus>;
/** Drain output bytes produced since the last call. */
takeOutput(): Uint8Array;
/** Visible screen as plain text. */
screenText(): string;
resize(rows: number, cols: number): void;
readonly rows: number;
readonly cols: number;
isAlternateScreen(): boolean;
/** Exit code once the shell has exited. */
readonly exitCode: number | undefined;
/** Live handle to the session's virtual filesystem. */
fs(): FileSystem;
}

/**
* Initialize the WebAssembly module. Must resolve before constructing `Bash`.
* Idempotent.
Expand Down
10 changes: 9 additions & 1 deletion crates/bashkit-wasm/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@
// so it loads correctly whether served from a CDN, a bundler, or a plain
// <script type="module">. No SharedArrayBuffer, no COOP/COEP headers required.

import init, { Bash, ExecResult } from "./bashkit_wasm.js";
import init, * as glue from "./bashkit_wasm.js";

const { Bash, ExecResult } = glue;

/**
* Interactive terminal session, or `undefined` when the bundle was built
* without the `terminal` cargo feature.
*/
export const Terminal = glue.Terminal;

let initPromise;

Expand Down
5 changes: 4 additions & 1 deletion crates/bashkit-wasm/scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ WORKSPACE_ROOT="$(cd "$CRATE_DIR/../.." && pwd)"
OUT_DIR="$CRATE_DIR/pkg"
TARGET=wasm32-unknown-unknown
PROFILE="${1:-release}"
# Cargo features, comma-separated. The npm package ships `terminal` (the
# `Terminal` class, ~60 KB gzipped); set BASHKIT_WASM_FEATURES="" to drop it.
FEATURES="${BASHKIT_WASM_FEATURES-terminal}"

echo "==> cargo build ($PROFILE) for $TARGET"
if [ "$PROFILE" = "release" ]; then
Expand All @@ -23,7 +26,7 @@ else
TARGET_SUBDIR="debug"
fi
# shellcheck disable=SC2086
cargo build -p bashkit-wasm --target "$TARGET" $CARGO_PROFILE_FLAG
cargo build -p bashkit-wasm --target "$TARGET" $CARGO_PROFILE_FLAG ${FEATURES:+--features "$FEATURES"}

WASM_IN="$WORKSPACE_ROOT/target/$TARGET/$TARGET_SUBDIR/bashkit_wasm.wasm"

Expand Down
22 changes: 16 additions & 6 deletions crates/bashkit-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ use wasm_bindgen_futures::{JsFuture, future_to_promise};
mod hostfs;
use hostfs::HostFs;

#[cfg(feature = "terminal")]
mod terminal;

/// Install a panic hook that forwards Rust panics to `console.error` with a
/// readable message and stack, instead of the default unhelpful
/// `RuntimeError: unreachable`.
Expand Down Expand Up @@ -816,6 +819,13 @@ fn set_property(object: &js_sys::Object, name: &str, value: &JsValue) -> Result<
}

fn build_core(config: &Config, sync_flag: &Arc<AtomicBool>) -> Result<CoreBash, JsError> {
let core = core_builder(config, sync_flag).build();
seed_files(&core.fs(), &config.files)?;
Ok(core)
}

/// Interpreter builder from parsed options, shared by `Bash` and `Terminal`.
fn core_builder(config: &Config, sync_flag: &Arc<AtomicBool>) -> bashkit::BashBuilder {
let profile = bashkit::ExecutionProfile::named(config.profile);
let mut builder = CoreBash::builder().profile(profile.clone());

Expand Down Expand Up @@ -866,11 +876,12 @@ fn build_core(config: &Config, sync_flag: &Arc<AtomicBool>) -> Result<CoreBash,
);
}

let core = builder.build();
builder
}

// Seed pre-created files as normal writable VFS entries.
for (path, content) in &config.files {
let fs = core.fs();
/// Seed pre-created files as normal writable VFS entries.
fn seed_files(fs: &Arc<dyn FileSystemTrait>, files: &[(String, String)]) -> Result<(), JsError> {
for (path, content) in files {
if let Some(parent) = Path::new(path).parent() {
let _ = fs.mkdir(parent, true).now_or_never();
}
Expand All @@ -879,8 +890,7 @@ fn build_core(config: &Config, sync_flag: &Arc<AtomicBool>) -> Result<CoreBash,
.ok_or_else(|| JsError::new("seeding files did not complete synchronously"))?
.map_err(|e| JsError::new(&e.to_string()))?;
}

Ok(core)
Ok(())
}

fn parse_options(options: &JsValue) -> Result<Config, JsError> {
Expand Down
Loading
Loading