Runtime-neutral PTY for Node, Bun, and Deno — one public contract, developer-selectable Backends.
English | 简体中文
Node, Bun, and Deno each expose a different PTY substrate — different installation models, I/O representations, lifecycle semantics, and native deployment constraints. UniPty turns that into one small, honest contract: applications pick a Backend explicitly, and all substrate variation stays behind a Core-owned seam. No implicit shell execution, no silent fallback to pipes, no runtime substitution.
Core plus one Backend of your choice — the package you install is the engine you get:
| Runtime | Install | Backend package you are getting |
|---|---|---|
| Node | npm install unipty @unipty/backend-node-pty |
third-party node-pty prebuilds |
| Node | npm install unipty @unipty/backend-zigpty |
third-party zigpty (Zig-built, zero-dependency) |
| Bun | bun add unipty @unipty/backend-bun |
runtime-native Bun.Terminal
|
| Deno | import via npm:@unipty/backend-deno-sigma__pty-ffi (run with -A for the FFI route) |
vendored @sigma/pty-ffi dynamic libraries |
Not sure which engine? The capability matrix under Choosing a Backend below tells you exactly what each one gives you.
import { UniPty } from "unipty";
import { createNodePtyBackend } from "@unipty/backend-node-pty";
const backend = await createNodePtyBackend(); // one-time readiness
const unipty = new UniPty({ backend });
const pty = unipty.spawn(["/bin/sh", "-i"], {
cwd: process.cwd(),
terminal: { cols: 120, rows: 40 },
});
for await (const text of pty.stream({ encoding: "utf8" })) {
process.stdout.write(text);
}
pty.write("echo hello\n"); // boolean Write Readiness — see the contract table
pty.resize(80, 24); // character cells only
pty.terminate(); // request, never cascades into close
pty.close(); // transport close, never kills the child
const { exitCode, signal } = await pty.exited; // independent observationSwapping engines is a one-line change — everything above is identical on
every route: createZigptyBackend() (zigpty),
createBunBackend() (bun), or
createDenoSigmaPtyFfiBackend() (deno).
Engine-specific behavior (options, permissions, capability differences) is
documented in each package's README.
Everything you can call, and exactly what it promises:
| Surface | Semantics |
|---|---|
spawn(argv, options) |
synchronous; argv is structured data; geometry resolves per dimension (explicit → COLUMNS/LINES → host TTY → 80×24) |
stream({ encoding }) |
one active view per PTY (active-stream otherwise); cancellation detaches the view only |
write(data) / drain()
|
boolean readiness; whole-value acceptance; typed saturation |
resize(cols, rows) |
finite positive integer character cells; unsupported is explicit |
close() / terminate()
|
idempotent, synchronous, non-cascading |
exited |
repeatably awaitable { exitCode, signal }, independent of stream completion and close |
| errors | stable error.code: unsupported, closed, backpressure, invalid-argument, active-stream
|
Four behaviors most often trip people up, by design:
-
Structured launch — Bun-style
spawn(argv, options)with a non-empty argv vector. No string-command overload, no implicit shell; metacharacters are ordinary data. -
Representation-selecting streams —
pty.stream({ encoding: "utf8" | "bytes" }). UTF-8 views prefer native text and otherwise decode bytes incrementally; bytes views yield native bytes only — re-encoded text is never claimed as raw output. -
Boolean Write Readiness —
write()returningfalsemeans "pause and awaitdrain()", never "retry"; saturation rejects one whole value with a typedbackpressurefailure. Never partial, never silent. -
Non-cascading lifecycle —
close()never kills the child,terminate()never closes the transport, andexitedsurvives both.unipty.dispose()blocks new spawns and waits for live PTYs before releasing the Backend exactly once.
| Package | Runtime | Substrate (stated honestly) |
|---|---|---|
@unipty/backend-node-pty |
Node | third-party node-pty via the @lydell/node-pty prebuilt distribution |
@unipty/backend-zigpty |
Node | third-party zigpty — Zig-built NAPI prebuilds bundled in the npm tarball (hard native gate, no pipe fallback) |
@unipty/backend-bun |
Bun | runtime-native Bun.Terminal (≥ 1.3.13 POSIX, ≥ 1.3.14 Windows) |
@unipty/backend-deno-sigma__pty-ffi |
Deno | third-party @sigma/pty-ffi over Rust portable-pty, vendored into a self-contained npm artifact |
The Node routes adapt third-party libraries — neither is a native Node runtime API, and the docs never claim otherwise. Deno is runtime metadata for the last route, not its implementation identity.
The public contract is identical on every route — structured argv, geometry and resize, write readiness with drain and whole-value saturation rejection, non-cascading close/terminate, bootstrap buffering, common error codes. The engines underneath are not. This matrix is the honest difference surface to consult before choosing a route: ✓ works out of the box, ⚠ needs an option or carries a documented limitation, ✗ not provided.
| Capability | node-pty |
zigpty |
bun |
deno-sigma__pty-ffi |
Notes |
|---|---|---|---|---|---|
Byte writes pty.write(Uint8Array)
|
✓ | ⚠ writeDecode option |
✓ | ✓ | zigpty's substrate write is string-only; writeDecode: true installs a stateful, split-safe decoder (fatal policies reject the whole value) |
Native text output (encoding:"utf8") |
✓ | ✓ | ✗ | ✗ | bun and deno are byte-native in both directions; their utf8 views are decoded incrementally by Core (lossless) |
| Windows target | ✓ ConPTY* | ⚠ runs, buffered output† | ✓ ≥ 1.3.14* | ✗ | *evidence-gated (see the catalog); †the zigpty engine ships Windows prebuilds and the route runs there, but the substrate's pause()/resume() are no-ops on win32, so output backpressure does not reach the kernel — enable the route's outputSpool option to bound memory by spilling to disk |
| Kernel-level output backpressure | ✓ master-socket pause | ✓ public pause/resume (unix) |
✗ none at transport level | ✗ internal channel + poll | node-pty pauses the master socket; zigpty pauses via its public API (post-exit through the repossessed stream) — on Windows that pause is inert and the adapter's outputSpool (bounded memory + disk spill) is the bound instead; bun documents no transport-level flow control; deno's FFI reader drains into an internal buffer |
| Independent transport-EOF signal | ✓ socket close event |
⚠ real signal + quiescence | ⚠ callback + synthesis | ✓ read-loop done
|
zigpty repossesses the master stream at exit (real end/close) with a 50 ms late-chunk-extending fallback; bun's Terminal exit callback is primary, exited-synthesis is the fallback |
| Transport read errors surfaced | ✓ unsupported
|
✗ indistinguishable from EOF | ✓ | ✓ unsupported
|
the zigpty substrate swallows stream errors entirely; the other three error the stream so a read failure is never silently presented as clean EOF |
| Signalled-death observation | signal name | signal name, exitCode: 0
|
signal name, exitCode: null
|
exitCode: 1, signal null
|
each substrate reports a different shape; adapters pass it through verbatim and never fabricate a value the engine did not report |
| Substrate distribution | platform sub-packages | zero-dep, in-tarball prebuilds (8 tuples) | built into the runtime | vendored dynamic libraries | deno additionally needs FFI permission (-A / --allow-ffi); zigpty ships no install scripts at all; node-pty installs only the current platform's binary |
Exec failures are an exit observation (never a spawn exception) on every route, and per-adapter details live in each package's README.
Manual import is the first-class path — Core never needs the acquisition layer:
const backend = await createBunBackend(); // or any official factory
const unipty = new UniPty({ backend });For deterministic discovery, @unipty/backend stages the work: pure
resolution (no imports), metadata-only inspection (no initialization), then
selected-candidate initialization whose failures are terminal and structured:
import { autoResolveUniPtyBackend } from "@unipty/backend";
const backend = await autoResolveUniPtyBackend({
candidates: ["@unipty/backend-node-pty"], // ordered preference
from: import.meta.url, // caller-rooted base
});Bundled deployments supply an explicit immutable manifest instead
(defineUniPtyBackendManifest()), generated by
unipty-helper-backend manifest --candidate <pkg> --out backend-manifest.ts.
See the acquisition README for the full staged
contract.
application code
│ public contract (spawn / stream / write / resize / lifecycle / exited)
▼
UniPty Core ──── owns every observable behaviour: views, conversion,
│ bootstrap buffering, backpressure, errors, lifecycle state
▼
Ready Backend ── one injected, already-ready object per UniPty instance
│ (native loading / connection / negotiation finished first)
▼
real PTY on node-pty / zigpty / Bun.Terminal / @sigma/pty-ffi
Design principles worth knowing before reading the code:
-
Substrate honesty. Every adapter documents its substrate's real
behaviour (kill-and-close primitives, unbounded internal buffers, signal
opacity) instead of papering over it; support claims are evidence-gated —
a tuple is
verifiedonly with a full public-contract pass against the installed artifact, and the release catalog is the sole source of that truth. -
No hidden policy. No implicit shell, no silent fallback to pipes, no
second plugin registry, no capability/asset protocol. Extension points
are explicit: Backend wrappers and opaque capability tokens
(
pty.capability(token), matched by object identity).
The full design narrative lives in 架构设计.md(中文).
| Package | npm | What it is |
|---|---|---|
unipty |
npm | The public Core: UniPty, Pty, the Backend/Endpoint seam, common errors |
@unipty/backend |
npm | Acquisition convenience: resolveUniPtyBackend, inspectUniPtyBackend, autoResolveUniPtyBackend, manifest constructor |
@unipty/helper-backend |
npm | Build-time manifest generator (unipty-helper-backend manifest) |
@unipty/backend-node-pty |
npm | Official Node route over third-party node-pty
|
@unipty/backend-zigpty |
npm | Official Node route over third-party zigpty (Zig-built NAPI prebuilds) |
@unipty/backend-bun |
npm | Official Bun route over runtime-native Bun.Terminal
|
@unipty/backend-deno-sigma__pty-ffi |
npm | Official Deno route over vendored @sigma/pty-ffi (self-contained npm artifact) |
@unipty/shell-parser |
npm | Optional ecosystem: argv/shell parsing over unbash
|
@unipty/powershell-parser |
npm | Optional ecosystem: PowerShell command parsing |
@unipty/conformance |
— (private) | Installed-package conformance harness, evidence writer, release catalog aggregator |
@unipty/www |
— (private) | Static documentation site → unipty.jixoai.com |
@unipty/example |
— (private) | Local demo: tabbed xterm terminals over WebSocket, one runtime per backend |
Every support claim flows through one seam: the public conformance suite runs against installed package artifacts (packed, installed into an isolated consumer, driven only through public exports). A full native pass emits one positive Verification Evidence record; a deterministic aggregator validates identity/tuple/commit uniqueness and emits the release catalog, which the documentation site consumes unchanged. Failures stay CI diagnostics — they never become permanent "unsupported" claims. The per-tuple truth for the current release is the compatibility catalog.
Local run:
pnpm --filter @unipty/conformance run conformance --backend node-pty --emit-evidenceWhere to go next, by intent:
| You want to… | Go to |
|---|---|
| Read the API in depth |
docs site · unipty README
|
| Pick an engine and see its options/limits | that route's README (linked from the routes table in Choosing a Backend) |
| Resolve Backends automatically or bundle for deploy | acquisition README · helper README |
| See what is verified per runtime/platform | compatibility catalog |
| Run a live terminal demo locally |
packages/example (pnpm example) |
| Understand the design decisions | 架构设计.md(中文) · capability specs |
| Contribute | 贡献规范.md(中文) |
| Report an issue / discuss | GitHub Issues |
Roadmap note: v1 is PTY-focused; persistence, reconnect, and remote hosts belong to replaceable Backends and wrappers, not a second plugin lifecycle.
corepack pnpm install
pnpm build && pnpm typecheck && pnpm test
pnpm --filter @unipty/backend-zigpty test # zigpty suite (real PTYs)
pnpm --filter @unipty/backend-bun test # Bun suite (needs Bun)
cd packages/backend-deno-sigma__pty-ffi && deno test -A test/ # Deno suite
pnpm check:arch # package-graph ownership rules