Skip to content

Latest commit

 

History

History
518 lines (440 loc) · 22.3 KB

EMBEDDING.md

File metadata and controls

518 lines (440 loc) · 22.3 KB

Embedding

There are two distinct dimensions: embedding the frontend into your app, and embedding yas server into your own service.

Your app, our components: @yas-run/react / @yas-run/solid

@yas-run/react and @yas-run/solid are workspace-first. Both are thin wrappers over @yas-run/core's YasTerminalSurface. A YasWorkspace owns connections, each connection owns terminals, and each YasTerminal renders a terminal by ID.

import {
  YasTerminal,
  YasWorkspaceProvider,
  useYasFocusedSession,
  useYasSessions,
  useYasWorkspace,
} from "@yas-run/react";
import { YasWorkspace } from "@yas-run/core";
import { useEffect, useMemo } from "react";

function EmbeddedYas({ wasm, passphrase }: { wasm: any; passphrase: string }) {
  const workspace = useMemo(
    () =>
      new YasWorkspace({
        wasm,
        connections: [
          {
            id: "default",
            transport: {
              type: "websocket",
              url: "wss://example.com/edge",
              passphrase,
            },
          },
        ],
      }),
    [passphrase, wasm],
  );

  useEffect(() => () => workspace.dispose(), [workspace]);

  return (
    <YasWorkspaceProvider workspace={workspace}>
      <TerminalScreen />
    </YasWorkspaceProvider>
  );
}

function TerminalScreen() {
  const workspace = useYasWorkspace();
  const sessions = useYasSessions();
  const focusedSession = useYasFocusedSession();

  useEffect(() => {
    if (sessions.length > 0) return;
    void workspace.createSession({
      connectionId: "default",
      rows: 24,
      cols: 80,
    });
  }, [sessions.length, workspace]);

  return (
    <YasTerminal
      sessionId={focusedSession?.id ?? null}
      style={{ width: "100%", height: "100vh" }}
    />
  );
}

Read-only terminals use the same sizing behavior as writable terminals; readOnly only disables mutating input. Terminals resize the remote session to their available grid by default. The grid stays at native size in the top-left corner of its container, with excess content clipped while a resize is pending. Pass resizable={false} for passive views or read-only transports that must preserve the host dimensions; this keeps the same native-size presentation. Add fitWidth to opt a passive preview into scaling and centering to fill the container's width, as used by the right-sidebar and Cmd-K B switcher previews. Use a flex container to center the preview vertically within a fixed height.

React API

API Purpose
new YasWorkspace({ wasm, connections }) Create a workspace with one or more transports
YasWorkspaceProvider Put the workspace, palette, and font settings in context
useYasWorkspace() Get the imperative workspace object
useYasWorkspaceState() Read the full reactive workspace snapshot
useYasConnection(connectionId?) Read one connection snapshot
useYasSessions() Read all terminals
useYasFocusedSession() Read the currently focused terminal
useYasWorkspaceConnection(workspace, id, transport) Manage a connection lifecycle with cleanup
YasTerminal Render one terminal by sessionId

Solid API

import {
  YasTerminal,
  YasWorkspaceProvider,
  createYasWorkspace,
  createYasWorkspaceState,
  createYasSessions,
  useYasFocusedSession,
} from "@yas-run/solid";
import { YasWorkspace } from "@yas-run/core";
import { createSignal, onCleanup, createEffect } from "solid-js";

function EmbeddedYas(props: { wasm: any; passphrase: string }) {
  const workspace = new YasWorkspace({
    wasm: props.wasm,
    connections: [
      {
        id: "default",
        transport: {
          type: "websocket",
          url: "wss://example.com/yas",
          passphrase: props.passphrase,
        },
      },
    ],
  });
  onCleanup(() => workspace.dispose());

  return (
    <YasWorkspaceProvider workspace={workspace}>
      <TerminalScreen />
    </YasWorkspaceProvider>
  );
}

function TerminalScreen() {
  const workspace = createYasWorkspace();
  const sessions = createYasSessions();
  const focusedSession = () => useYasFocusedSession(workspace);

  createEffect(() => {
    if (sessions().length > 0) return;
    workspace.createSession({ connectionId: "default", rows: 24, cols: 80 });
  });

  return (
    <YasTerminal
      sessionId={focusedSession()?.id ?? null}
      style={{ width: "100%", height: "100vh" }}
    />
  );
}
API Purpose
new YasWorkspace({ wasm, connections }) Create a workspace with one or more transports
YasWorkspaceProvider Put the workspace, palette, and font settings in context
createYasWorkspace() Get the imperative workspace object from context
createYasWorkspaceState(workspace?) Reactive signal tracking the workspace snapshot
createYasSessions(workspace?) Reactive signal tracking all terminals
useYasSession(workspace, sessionId) Look up a single terminal by ID (non-reactive)
useYasFocusedSession(workspace) Look up the focused terminal (non-reactive)
useYasConnection(workspace, sessionId) Look up a connection snapshot (non-reactive)
createYasWorkspaceConnection(workspace, id, transport) Manage a connection lifecycle with onCleanup
YasTerminal Render one terminal by sessionId

Wayland surface rendering (experimental)

YasSurfaceView renders a single Wayland surface from a terminal's compositor. The server encodes each surface as H.264 or AV1; the component decodes via WebCodecs and draws to a canvas.

By default the view participates in sizing its surface: the largest logical rectangle that fits all active viewers is requested, subject to the application's minimum/maximum size hints. The view is fully interactive. Pass resizable={false} for a passive preview — a dock card, a switcher thumbnail — that shares another view's stream. Such a view is served a fixed downscale capped at a thumbnail cadence and takes no input at all, so it is the wrong choice for anything the user clicks in.

zoom scales the surface independently of the pane's pixel size: zoomMode "relative" (the default) multiplies the display's DPI by zoom, while "exact" uses zoom as the absolute surface scale. Only resizable views drive the scale.

Resizable views display decoded pixels 1:1 when codec rounding leaves the frame within two device pixels of its intended size on both axes. This avoids an extra browser resize on lower-DPI viewers sharing a HiDPI surface; a small gap at the right or bottom is preferable to filtering the text again. Adaptive frames with substantially lower resolution still fill their logical window size, anchored at the top-left. While a resize is in flight, the old frame keeps its intended scale and is clipped by the pane until the resized frame arrives. Only a committed application minimum forces uniform zoom-out; an oversized frame alone never does. Smaller windows stay at their intended scale rather than filling unused space. Minimum-forced zoom-out also expands the logical size offered to the application: if width forces a 360×780 pane to fit a 500px-wide window, it offers approximately 1083 logical pixels of height. These adjusted bounds are intersected across viewers, and reset when the application releases its minimum.

import { YasSurfaceView } from "@yas-run/react";

function AppWindow({
  connectionId,
  surfaceId,
}: {
  connectionId: string;
  surfaceId: number;
}) {
  return (
    <YasSurfaceView
      connectionId={connectionId}
      surfaceId={surfaceId}
      style={{ width: 800, height: 600 }}
    />
  );
}

touchMode chooses how touchscreen contacts reach the app. The default, "direct", forwards every contact to the app's own wl_touch, so pinch, rotate, and multi-finger gestures belong to the app. Set "pointer" to opt out and use YAS's compatibility gestures: tap to click, one-finger drag to scroll, and long-press for right-click. A server without multitouch support automatically keeps the pointer mapping. The mode is safe to change at runtime and does not restart the video stream. Trackpads and pens are unaffected.

<YasSurfaceView
  connectionId={connectionId}
  surfaceId={surfaceId}
  touchMode="pointer"
/>

Every terminal has an experimental Wayland compositor available. Any command — shell, TUI, or GUI — can open Wayland surfaces:

workspace.createSession({
  connectionId: "default",
  rows: 24,
  cols: 80,
  command: "my-gui-app",
});

Surfaces created by the terminal appear in the connection's surfaceStore, keyed by the terminal's PTY ID. Each surface has a surfaceId, parentId, title, appId, width, and height.

Workspace operations

  • createSession({ connectionId, rows, cols, tag?, command?, cwdFromSessionId? })
  • closeSession(sessionId)
  • restartSession(sessionId)
  • focusSession(sessionId | null)
  • search(query, { connectionId? })
  • setVisibleSessions(sessionIds)
  • addConnection(...) / removeConnection(connectionId) / reconnectConnection(connectionId)

Client identifiers

A connection can report an identifier of your choosing (a user, a device, your app's own session ID) to name it in the server's client list: yas client list, and Manage → Clients in the browser. That list shows each client's terminal and surface view sizes, and a shared terminal or window is sized to fit the smallest, so the identifier is how to tell whose view that is. YAS passes it on as is: it must be UTF-8 of at most 1 KiB and nothing else is checked, and several clients may report the same one.

new YasWorkspace({
  wasm,
  connections: [{ id: "default", transport, clientIdentifier: "alice@laptop" }],
});

On a YasConnection of your own, pass clientIdentifier in its options, and call updateClientIdentifier(text) to replace it on the live session. The Rust client takes HelloOptions::identifier and Client::set_identifier.

Transports

All transports share a common set of options (YasTransportOptions):

Option Default Description
reconnect true Auto-reconnect on disconnect
reconnectDelay 500 Initial reconnect delay (ms)
maxReconnectDelay 10000 Maximum reconnect delay (ms)
reconnectBackoff 1.5 Backoff multiplier
connectTimeoutMs none (WS) / 10000 (WebRTC) Connection timeout (ms)
// Authenticated native YAS edge.
const edge = { type: "websocket", url, passphrase, options };

// Native read-only WebRTC share through the signaling hub.
const share = { type: "share", hubUrl, passphrase };

// Low-level native byte-stream transport for an existing peer connection.
const dataChannel = createWebRtcDataChannelTransport(peerConnection);
// Its ordered channel selector is `yas.v1`.

Or implement your own:

interface YasTransport {
  connect(): void;
  send(data: Uint8Array): void;
  close(): void;
  readonly status: ConnectionStatus;
  readonly authRejected: boolean;
  readonly lastError: string | null;
  addEventListener(type: "message" | "statuschange", listener: Function): void;
  removeEventListener(
    type: "message" | "statuschange",
    listener: Function,
  ): void;
}

Rust: yas-client

crates/client (package yas-client) is the Rust client the yas CLI is built on. It is not on crates.io yet; depend on it by path or git:

yas-client = { path = "../yas/crates/client" }
tokio = { version = "1", features = ["full"] }

Its TLS (wss://, TURN), QUIC (wt://) and uplink crypto run on ring by default. A program built on aws-lc-rs, rustls's own default, keeps ring out of its binary with default-features = false, features = ["aws-lc-rs"]. The TLS clients YAS makes use the program's process-wide rustls provider when it installed one (CryptoProvider::install_default), else the feature's (aws-lc-rs when both are on), so they don't panic in a program that has both.

Client::connect(target, &ConnectOptions) accepts every target the CLI does (local[:NAME], socket:PATH, ssh:[USER@]HOST, tcp:, ws(s)://, wt://, uplink:, share:, remote names); Client::from_stream speaks YAS over any byte stream you already have. A Client is one session, Clone and safe to use from many tasks at once: each process stream, file transfer and subscription is flow-controlled on its own.

use yas_client::{Client, ConnectOptions, process::Command};

let client = Client::connect(Some("ssh:build@ci"), &ConnectOptions::named("my-app")).await?;
let output = client.spawn(Command::new("cargo").args(["test", "--workspace"]).current_dir("/src/app"))
    .await?
    .output()
    .await?;
println!("{} {}", output.status, String::from_utf8_lossy(&output.stdout));
  • Processes (process): argv/env/cwd, piped or null stdin, merged stderr, detachable, operation IDs for retries, wait/signal/kill, list/watch. Each command gets its own process group; when the command exits the server terminates the group (SIGTERM, then SIGKILL after YAS_PROCESS_KILL_GRACE, 2 s), so background children die with it unless they setsid. Ordinary processes die with their session; detachable ones survive it. The module docs spell this out. A server admits 16 live processes per session and 64 in total by default; yas server --process-max-per-session N (and the other --process-max* flags) raises that, and Client::process_limits() reports what the server enforces.
  • Files (fs): open a root, read (whole, limited, ranged) with BLAKE3 hashes, stat (lstat semantics), list, write with preconditions (Any, Absent, Hash) through a staged commit, mkdir -p, rename, remove, symlink.
  • KV and environment (kv): get/put/delete with preconditions, list, watch; the server environment (ENV_GET).
  • Terminals (terminal): PTYs that belong to the server, not the session, with the IDs yas terminal list shows. start_terminal with a TerminalCommand (a program, a shell command line or the default shell; cwd, env, size, tag, deadline), then type into it (write_terminal), read its screen as text, resize, signal, restart, close, and wait for it to exit. When its shell reports commands (OSC 133, docs/shell-integration.md): wait_terminal_command, terminal_commands (exit codes, command lines) and terminal_output (what one command printed); terminal_cwd with OSC 7.
  • Surfaces (surface): the windows GUI programs map on the server's compositor. List them, capture one as PNG or AVIF, click, scroll, press keys (key_combo("ctrl+c"), typed_keys("hello{enter}")) or enter text, resize, focus and close them: yas surface, for programs that drive GUIs.
  • Errors (Error): connection failures, lost sessions, server statuses (is_not_found, is_conflict), timeouts, unsupported operations, protocol violations.
  • SSH (ssh, re-exported yas-ssh): SshOptions::in_memory(HostKeyPolicy::Pinned(keys)) with with_private_key(text, passphrase) authenticates with keys held in memory and trusts only pinned host keys, touching no ~/.ssh file; pass SshPool::with_options(options) as ConnectOptions::ssh. Hosts whose sshd does not forward to sockets, or that have no POSIX shell (Windows), are reached by running yas connect --stdio there: automatically in SshMode::Auto, only that way in SshMode::Exec (SshOptions::remote_command overrides the command). A refused host key is ssh::Error::HostKey, with the presented key's fingerprint.
  • Hosting (host, Unix): HostedServer::start(HostOptions::new("yas")) runs a private yas server --fd-channel child in a 0700 directory (own state, cache and runtime directories by default); connect() hands it a fresh socketpair per session, socket_path() exposes its private socket for YAS_SOCK, and dropping it (or the host process dying) stops the server.
  • Pipes: Transport::from_split(child_stdout, child_stdin) and Client::from_transport run a session over a child's pipes. Run yas connect --stdio at the other end (an SSH exec channel, or docker exec -i CONTAINER yas connect --stdio) and it relays them to that side's server (docs/transports.md).
  • Read-only viewers: wire::read_only::ReadOnlyIngress turns the bytes a client sends into a read-only session's (it rewrites the HELLO, then passes everything through), for a relay that forwards clients to an ordinary socket; yas server --read-only-sock PATH is a socket that does it for every session (docs/transports.md).

cargo run -p yas-client --example run -- local -- uname -a is a complete example; crates/cli/tests/client_host.rs exercises the API end to end.

Server-side: a Node/Bun client over a unix socket

You can also run a @yas-run/core client server-side (Node/Bun/Deno) to drive a local yas server over its unix-domain socket — e.g. to script terminals or run headless commands. The non-browser building blocks live under the @yas-run/core/node subpath (kept out of the package root so node:net and runtime globals never leak into browser bundles). The packages are plain ES modules; install @yas-run/core with its matching @yas-run/browser peer:

npm install @yas-run/core @yas-run/browser
import { YasWorkspace, exitCodeFromStatus, nullLogger } from "@yas-run/core";
import { NodeUnixSocketTransport, loadYasWasm } from "@yas-run/core/node";

// `loadYasWasm()` initializes the @yas-run/browser WASM off-browser: it reads
// the colocated yas_browser_bg.wasm from disk and feeds it to init(), so you
// never touch raw wasm bytes. (If you depend on a self-initializing
// `@yas-run/browser/node` build it is returned as-is.)
const wasm = await loadYasWasm();

const socket = process.env.YAS_SOCK;
if (!socket) {
  throw new Error("YAS_SOCK must name the server's explicit Unix socket");
}
const transport = new NodeUnixSocketTransport(socket);
const workspace = new YasWorkspace({
  wasm,
  logger: nullLogger, // no-op logger; omit to log lifecycle events to console
  connections: [{ id: "default", transport }],
});

// Families are negotiated after the HELLO round trip; wait before creating.
await new Promise<void>((resolve) => {
  const check = () => {
    if (!workspace.getSnapshot().ready) return;
    unsubscribe();
    resolve();
  };
  const unsubscribe = workspace.subscribe(check);
  check();
});

const session = await workspace.createSession({
  connectionId: "default",
  rows: 24,
  cols: 80,
  command: "my-command",
});

The unix transport carries the YAS byte stream (the preface, then 4-byte little-endian length-prefixed frames) for you; there is no need to re-implement the wire format. BunUnixSocketTransport and DenoUnixSocketTransport are the runtime-native equivalents; Deno needs --allow-read --allow-write for the socket path.

Exit status

When a session's process exits, its YasSession.state becomes "exited" and YasSession.exitStatus carries the raw status from the server:

  • >= 0 — normal exit; the value is the exit code.
  • < 0 — terminated by a signal; the value is the negated signal number.
  • EXIT_STATUS_UNKNOWN — not yet collected.

exitCodeFromStatus(status) maps that to a conventional shell exit code (unknown → 1, signalled → 128 + signal), and formatExitStatus(status) renders "exited(<code>)" / "signal(<n>)". Both mirror the yas CLI.

import { exitCodeFromStatus } from "@yas-run/core";

workspace.subscribe(() => {
  for (const s of workspace.getSnapshot().sessions) {
    if (s.state === "exited" && s.exitStatus !== null) {
      console.log(`${s.id} exited with code`, exitCodeFromStatus(s.exitStatus));
    }
  }
});

Your service, our server: fd-channel mode

fd-channel lets an external process own yas server's lifecycle and control which clients connect via SCM_RIGHTS fd passing. See the transport reference and the working examples:

Uplink connections

@yas-run/core/transports exports YasUplinkTransport, which connects directly through an uplink relay using browser WebCrypto (X25519, AES-GCM, SHA-256). Pass the producer's pinned connection URL and the client's 43-character base64url private key separately: new YasUplinkTransport(url, privateKey). generateUplinkKeyPair() generates an identity and uplinkPublicKey(privateKey) derives its public key. Authorize that public key on the producer.

YasWorkspace accepts the equivalent { type: "uplink", url, identity: privateKey } transport configuration. Identity storage belongs to the embedding app; the transport does not persist keys. The app must be served from a trusted origin independently of the relay, and the relay control endpoint must permit that origin through CORS.

YasNoiseTransport wraps custom opaque carriers and supports optional encrypted routed datagrams. See the uplink protocol for carrier requirements, limits, key rotation, and migration from Ed25519/TLS.