Skip to content

Documentation

Using UniPty

The public contract, how to acquire a Backend, what each official route really is, and what a static documentation site can and cannot do in a browser.

Layer 1

Core owns the public surface

Streams, bootstrap buffering, UTF-8 conversion, backpressure, common error codes, and lifecycle state. One UniPty instance owns one ready Backend and may create multiple independent PTYs.

Layer 2

The Endpoint is the seam

Each Backend supplies a Core-private Endpoint: one ordered native chunk source, synchronous write with drain, resize, terminate/close, and a repeatable exited promise — independent of transport EOF.

Layer 3

Backend readiness precedes Core

Factories or .ready() perform one-time runtime loading, connection, and capability negotiation before new UniPty(options). After that, spawn, write, resize, terminate, and close stay synchronous.

Install

Core plus the engine you choose

The Backend package you install is the engine you get. Not sure which one? The capability matrix under the route table tells you exactly what each engine provides.

RuntimeInstallEngine
Nodenpm install unipty @unipty/backend-node-ptythird-party node-pty prebuilds
Nodenpm install unipty @unipty/backend-zigptythird-party zigpty (Zig-built, zero-dependency)
Bunbun add unipty @unipty/backend-bunruntime-native Bun.Terminal
Denoimport via "npm:@unipty/backend-deno-sigma__pty-ffi"vendored @sigma/pty-ffi dynamic libraries

Swapping engines is a one-line change — acquire a different Backend and everything else stays identical:

engine swap
import { UniPty } from "unipty";
import { createNodePtyBackend } from "@unipty/backend-node-pty";
import { createZigptyBackend } from "@unipty/backend-zigpty";
import { createBunBackend } from "@unipty/backend-bun";
import { createDenoSigmaPtyFfiBackend } from "@unipty/backend-deno-sigma__pty-ffi";

// Pick the engine by acquiring a different Backend — every line below the
// constructor is identical on all four routes:
const unipty = new UniPty({ backend: await createZigptyBackend() });

Engine-specific options (encoding, writeDecode, queue tuning, FFI permissions) and behavioral limits live in each package's README, linked from the route table below.

Core usage

The public contract

Core never loads, names, or resolves a Backend for you. Every operation below is runtime-neutral and identical across Node, Bun, and Deno.

Construct with a ready Backend

A factory (or .ready()) performs one-time runtime loading first; then Core accepts the ready instance. The concrete Backend type is preserved and exposed read-only.

construct
import { UniPty } from "unipty";
import { createNodePtyBackend, NodePtyBackend } from "@unipty/backend-node-pty";

const backend: NodePtyBackend = await createNodePtyBackend();
const unipty = new UniPty({ backend });

unipty.backend === backend; // readonly, concrete type preserved

Spawn with structured argv

The launch entry is unipty.spawn(argv, options): argv is non-empty, its first value is the executable, and there is no string-command overload. Core never implicitly invokes a shell. Initial geometry lives under terminal: { cols, rows } in character cells; omitted dimensions resolve independently from the value, COLUMNS/LINES, a trusted host TTY probe, then 80 × 24.

spawn
const pty = unipty.spawn(["/bin/sh", "-i", "-l"], {
  terminal: { cols: 120, rows: 40 },
  env: { TERM: "xterm-256color" }, // launch context; never overrides geometry
});

One stream per PTY

stream() selects the representation: Terminal Text (ReadableStream<string>) or Terminal Bytes (ReadableStream<Uint8Array>). One active stream per PTY — a second call fails with the active-stream code; use caller-owned tee() for fan-out. Cancelling the stream detaches that view only: it never closes input and never terminates the child. Startup output is preserved in a bounded bootstrap buffer until the first view attaches.

stream
const text = pty.stream({ encoding: "utf8" }); // ReadableStream<string>
const bytes = pty.stream({ encoding: "bytes" }); // only after the first detaches

const reader = text.getReader();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  render(value);
}

Write with boolean readiness

write() accepts string | Uint8Array and returns a boolean. Either return value means the whole value was accepted exactly once; false only means “pause and drain”. Backpressure is advisory, but saturation rejects one whole value with the backpressure code — never a partial accept, silent drop, or unbounded queue.

write
if (!pty.write("ls -la\r")) {
  await pty.drain(); // readiness recovery, not a physical flush
}

Resize

resize(cols, rows) takes positive integer character cells only. Pixel dimensions stay Backend-specific; a Backend that cannot resize reports unsupported explicitly.

resize
pty.resize(120, 40);

Terminate and close are non-cascading

terminate() is an idempotent synchronous termination request. close() is an idempotent synchronous logical close: it publishes closed before returning, invalidates all I/O surfaces, and lets an active stream complete normally — but it does not terminate the child, and terminate does not close the transport.

lifecycle
pty.terminate(); // request only; exit is observed independently
pty.close();     // publishes closed; further write/resize/stream() reject with "closed"
console.log(pty.closed);

Exit is an independent observation

exited is a repeatable promise for { exitCode, signal }. It is independent of transport EOF, stream cancellation, and close: an already-established exit observation survives close, and signal records the observed termination cause, not a general kill(signal) vocabulary. Exec failures surface as an exit observation (never a spawn exception) on every route; signalled deaths keep the engine's own shape — see the capability matrix for what each route reports.

exit
const result = await pty.exited;
console.log(result.exitCode, result.signal); // number | null, string | null

Capabilities and error codes

Backend extensions ride on opaque capability tokens looked up by object identity — no string registry, no fallback. Operational failures carry stable codes: unsupported, closed, backpressure, invalid-argument, active-stream.

capability
import { defineCapabilityToken } from "unipty";

// Capability tokens are Backend-owned singletons; Core matches object
// identity only (no string registry, no name fallback). No official
// Backend ships a token yet — this is the intended shape when one does:
interface SignalsCapability { kill(signal: string): void }
const signalsCapability = defineCapabilityToken<SignalsCapability>();

const signals = pty.capability(signalsCapability);
if (signals) signals.kill("SIGHUP"); // Backend vocabulary; explicit, never silent

Dispose the Backend owner

UniPty.dispose() blocks new spawns, keeps existing PTYs caller-owned, waits for them to close, then releases shared Backend resources exactly once. Repeated calls reuse one promise.

dispose
await unipty.dispose();

Backend acquisition

Acquiring a ready Backend

Manual import is the first-class path and never goes away; AutoResolve conveniences over it; pure resolution and inspection stay effect-free.

Manual import — the first-class path

manual
const { createBunBackend } = await import("@unipty/backend-bun");
const backend = await createBunBackend();
const unipty = new UniPty({ backend });

AutoResolve

autoResolveUniPtyBackend analyzes the current runtime, processes your explicit candidates first (unavailable candidates emit a structured warning), then falls back to candidates inferred from your package.json dependencies. Fallback requires exactly one compatible result; several produce ambiguous. The selected candidate’s initialization is terminal — a failure is reported with the structured backend-initialization code, never silently retried with the next Backend.

autoresolve
import { autoResolveUniPtyBackend } from "@unipty/backend";

const backend = await autoResolveUniPtyBackend({
  candidates: ["@unipty/backend-node-pty", "@unipty/backend-bun"],
  from: import.meta.url, // caller-rooted base
  onWarning: (warning) => console.warn(warning.code, warning.packageName),
});

Pure resolution and inspection

resolveUniPtyBackend resolves one package location at a time and requires an explicit caller from: URL; inspectUniPtyBackend imports only the side-effect-free metadata subpath — never the Backend entry module or factory. Neither stage initializes anything.

resolve
import { resolveUniPtyBackend, inspectUniPtyBackend } from "@unipty/backend";

const report = await resolveUniPtyBackend("@unipty/backend-deno-sigma__pty-ffi", {
  from: import.meta.url,
});
if (report.status === "resolved") {
  const inspection = await inspectUniPtyBackend(report);
  if (inspection.status === "compatible") {
    /* metadata-compatible with this Core; still no native initialization */
  }
}

Bundled deployments: explicit manifest

Bundlers cannot resolve runtime package graphs. Generate an explicit build-time manifest with the helper CLI, then let AutoResolve select from it. Generated modules default-export one manifest, statically import each package’s ./unipty.metadata, and keep Backend entry imports inside deferred loaders — evaluating the manifest imports no Backend entry and initializes nothing.

helper CLI
pnpm unipty-helper-backend manifest \
  --candidate @unipty/backend-node-pty \
  --candidate @unipty/backend-bun \
  --candidate @unipty/backend-deno-sigma__pty-ffi \
  --out src/unipty-backends.manifest.ts
manifest
import backendManifest from "./unipty-backends.manifest";

const backend = await autoResolveUniPtyBackend({
  manifest: backendManifest,
  candidates: ["@unipty/backend-node-pty"],
});

Official routes

Substrates, stated honestly

Every official package states its substrate in metadata provenance. None of these declarations is a support claim — only the evidence catalog can say verified.

PackageRuntimeSubstrateNotes
@unipty/backend-node-ptyNodenode-pty via @lydell/node-pty prebuildsA third-party native addon with prebuilt binaries. Node has no native PTY API; this route wraps the ecosystem’s standard substrate rather than pretending otherwise.
@unipty/backend-zigptyNodezigpty (Zig-built NAPI prebuilds)A second Node route over the Zig-implemented substrate. Writes are text-native (bytes need the writeDecode option) and readiness fails closed with the typed unsupported error on tuples without a prebuild, instead of silently degrading to a pipe.
@unipty/backend-bunBunBun.TerminalBun’s built-in terminal API: Linux/macOS since Bun 1.3.13, Windows via ConPTY since 1.3.14. Support is versioned evidence, not a blanket claim.
@unipty/backend-deno-sigma__pty-ffiDeno@sigma/pty-ffi (Rust portable-pty)An npm-only package whose build vendors the @sigma/pty-ffi/noinit JavaScript closure and targeted dynamic libraries. Run Deno with -A or --allow-ffi --allow-read --allow-run (terminate() discovers the child pid via pgrep); no default download or cache.

Capability differences (what the engines actually give you)

The public contract is identical on every route; the engines underneath are not. ✓ works out of the box, ⚠ needs an option or carries a documented limitation, ✗ not provided.

Capabilitynode-ptyzigptybundeno-ffiNotes
Byte writes pty.write(Uint8Array)⚠ writeDecode optionzigpty'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 both ways; their utf8 views are decoded incrementally by Core (lossless).
Windows target✓ ConPTY*⚠ runs, buffered†✓ ≥ 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 — the route's outputSpool option bounds memory by spilling to disk.
Kernel-level output backpressure✓ socket pause✓ public pause/resume (unix)✗ none at transport✗ internal channelnode-pty pauses the master socket; zigpty pauses via its public API (inert on Windows, where the adapter's outputSpool is the bound instead); bun documents no transport-level flow control; deno's FFI reader drains into an internal buffer.
Independent transport-EOF signal✓ close event⚠ real + fallback⚠ callback + fallback✓ read-loop donezigpty 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✓ unsupportedthe 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 observationsignal namesignal name, exitCode 0signal name, exitCode nullexitCode 1, signal nulleach engine reports a different shape; adapters pass it through verbatim and never fabricate a value the engine did not report.
Substrate distributionplatform sub-packageszero-dep in-tarball (8 tuples)built into the runtimevendored dynamic librariesdeno additionally needs FFI permissions; 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. Per-adapter details and options live in each package's README.

Metadata protocol

./unipty.metadata, side-effect free

Every official Backend package exposes a side-effect-free ./unipty.metadata subpath. The minimum schema carries package identity, Backend identity, the factory export name, the Core protocol, and target declarations for side-effect-free prefiltering — and that is all it does.

unipty.metadata
{
  "schema": 1,
  "package": { "name": "@unipty/backend-node-pty", "version": "0.2.0" },
  "backend": { "id": "node-pty", "factoryExport": "createNodePtyBackend" },
  "protocol": { "core": [1] },
  "targets": [{ "runtime": "node" }],
  "provenance": {
    "kind": "third-party",
    "substrate": "node-pty (@lydell/node-pty prebuilt distribution)"
  }
}

Target declarations use normalized Node/npm tokens: os follows process.platform/npm os, arch follows process.arch/npm cpu, and libc is an independent, Linux-only axis for native evidence. Optional provenance describes the implementation kind and substrate; metadata contains no maturity, capability, or verified-support claim.

Browser limits

No PTY in a browser tab

Browsers expose no pseudo-terminal API, and UniPty does not pretend otherwise. This website is a static documentation surface.

  • It never imports or initializes a native Backend in the browser.
  • It never executes local PTY operations; there is nothing to spawn here.
  • Its compatibility page is fully pre-rendered at build time from one release catalog artifact — no browser-side evidence recomputation.

Running a terminal for browser clients means hosting a UniPty Backend outside the browser and streaming over a transport — an arrangement v1 deliberately leaves to Backend owners rather than Core.