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.