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.
Core usage
Construct with a ready Backend
Core never loads, names, or resolves a Backend for you. 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.
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.
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.
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.
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.
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.
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.
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.
import { signalsCapability } from "@unipty/backend-node-pty";
const signals = pty.capability(signalsCapability.token);
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.
await unipty.dispose();
Backend acquisition
Manual import — the first-class path
Import an official Backend package, call its async factory, and pass the ready result to Core. This path never goes away.
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.
import { autoResolveUniPtyBackend } from "@unipty/backend";
const backend = await autoResolveUniPtyBackend({
candidates: ["@unipty/backend-node-pty", "@unipty/backend-bun"],
onWarning: (warning) => console.warn(warning.code, warning.message),
});
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.
import { resolveUniPtyBackend, inspectUniPtyBackend } from "@unipty/backend";
const report = 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.
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
import backendManifest from "./unipty-backends.manifest";
const backend = await autoResolveUniPtyBackend({
manifest: backendManifest,
candidates: ["@unipty/backend-node-pty"],
});
Official routes
Every official package states its substrate honestly in metadata provenance. None of these declarations is a support claim — only the evidence catalog can say verified.
| Package | Runtime | Substrate | Notes |
|---|---|---|---|
@unipty/backend-node-pty |
Node | node-pty via @lydell/node-pty prebuilds |
A 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-bun |
Bun | Bun.Terminal |
Bun'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-ffi |
Deno | @sigma/pty-ffi (Rust portable-pty) |
An npm-only package whose build vendors the
@sigma/pty-ffi/noinit JavaScript closure and targeted dynamic libraries.
Requires explicit Deno FFI permission; no default download or cache.
|
Metadata protocol
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. Optional provenance describes the implementation kind and
substrate — and that is all it does: metadata contains no maturity, capability, or
verified-support claim.
{
"schema": 1,
"package": { "name": "@unipty/backend-node-pty", "version": "0.1.0" },
"backend": { "id": "node-pty", "factoryExport": "createNodePtyBackend" },
"protocol": { "core": [1] },
"targets": [
{
"runtime": "node",
"os": ["darwin", "linux"],
"arch": ["arm64", "x64"],
"libc": ["glibc"]
}
],
"provenance": { "kind": "adapter", "substrate": "node-pty" }
}
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.
Browser-local PTY limits
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.