npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@voxalsh/sdk

v0.1.0

Published

SDK for building voxal interactive terminal apps reachable over SSH

Downloads

88

Readme

@voxal/sdk

The library app developers import to build interactive terminal apps that anyone can reach with ssh app@host. It is the in-sandbox half of voxal's host/sandbox split: the SDK gives an app a small, friendly API (createApp, conn.write, fetch, …) and translates it into the handful of low-level globals the trusted host injects.

This README is the in-depth reference for people working on voxal. If you just want to build an app, the Quick start and API reference are enough; the rest documents the contract, the runtime, and the build/deploy pipeline that the SDK is wired into.

  • Core (@voxal/sdk): zero runtime dependencies, pure ES2022, one file (index.js)
    • its types (index.d.ts).
  • Optional UI layer (@voxal/sdk/ui): build TUIs as React components with flexbox layout, 1-to-1 with Ink. This is the only part with dependencies (react-reconciler + yoga-wasm-web/asm, both pure JS; react is a peer dep) — and they're pulled in only if an app imports it.
  • No build step — the CLI bundles whatever you import into each app at voxal deploy time.
  • The only coupling to the rest of the system is the host contract; keep that surface tiny and stable.

Contents


Where this sits

voxal has one core idea: a host / sandbox split. A trusted host process holds the dangerous things — the SSH connections and real network access. Untrusted app code runs inside a sandbox that can do pure JavaScript and nothing else, except through narrow bridges the host injects.

ssh hello@host
  → gateway: SSH handshake, NO shell, username "hello" = app name
  → runtime: warm hello's sandbox (or boot it), assign a connection id
  → SDK: app.onConnect(conn) renders; keystrokes → app.onKey(conn, data)
  → conn.write(...) → host writes ANSI to that SSH channel
  → fetch(...) runs in the HOST, the result is handed back into the sandbox

The SDK is the code that runs inside that sandbox. It never sees a socket, a file, or the network directly — it only ever calls the injected globals. One sandbox per app multiplexes every user of that app; idle apps are dropped (scale-to-zero). For the host side of all this, see server/runtime.js, server/safe-fetch.js, and the root project.md.


Quick start

npm create voxal-app myapp   # scaffolds app.js + voxal.json against @voxal/sdk
cd myapp && npm install
// app.js
import { createApp, fetch } from '@voxal/sdk';

const CLEAR = '\x1b[2J\x1b[H'; // clear screen + cursor home

createApp()
  .onConnect((conn) => {
    conn.write(CLEAR);
    conn.write(`hello — ${conn.cols}x${conn.rows}\r\n`);
    conn.write('[c] cat fact   [q] quit\r\n');
  })
  .onKey(async (conn, data) => {
    if (data === 'q' || data === '\x03') return conn.close(); // q or Ctrl-C
    if (data === 'c') {
      try {
        const res = await fetch('https://catfact.ninja/fact');
        conn.write('🐱 ' + JSON.parse(res.body).fact + '\r\n');
      } catch (err) {
        conn.write('(fetch failed: ' + err.message + ')\r\n');
      }
    }
  })
  .onResize((conn) => conn.write(`resized to ${conn.cols}x${conn.rows}\r\n`))
  .onClose(() => { /* per-session cleanup */ })
  .listen();
voxal login
voxal deploy        # bundles, boot-checks, and ships app.js
ssh myapp@host      # anyone can now reach it

A full, annotated example lives in examples/hello/app.js.


The mental model

Your app is a server; each terminal is a connection. A single instance of your code handles every user that SSHes in — they are multiplexed into one sandbox. So:

  • There is exactly one module scope. Module-level variables are shared by all sessions. That is great for shared state (a leaderboard, a chat room) and a footgun for per-session state.
  • Hold per-session state keyed by conn.id (in a Map) or in a closure, and free it in onClose.
  • Handlers should return fast. Heavy synchronous work blocks the app for everyone and is bounded by a CPU timeout (see runtime); offload or chunk it. await fetch(...) is fine — it yields.
const sessions = new Map(); // conn.id -> { name, score }

createApp()
  .onConnect((conn) => { sessions.set(conn.id, { score: 0 }); })
  .onKey((conn, data) => {
    const s = sessions.get(conn.id);
    if (data === ' ') s.score++;
    conn.write(`\rscore: ${s.score}`);
  })
  .onClose((conn) => { sessions.delete(conn.id); }) // always clean up
  .listen();

API reference

The package has two named exports: createApp and fetch.

import { createApp, fetch } from '@voxal/sdk';

createApp()

Returns a chainable App. Register handlers, then call .listen(). Nothing runs until listen() is called — that is what wires the app to the host.

const app = createApp();

App — the handler builder

Each on* method registers one (optional) handler and returns the same App for chaining. Each handler may be async. Call .listen() exactly once, last.

| Method | Handler signature | Fires when | | --- | --- | --- | | onConnect(fn) | (conn) => void \| Promise<void> | a new session opens | | onKey(fn) | (conn, data) => void \| Promise<void> | the user sends input (a keystroke) | | onResize(fn) | (conn, size) => void \| Promise<void> | the terminal window is resized | | onClose(fn) | (conn) => void \| Promise<void> | the session ends | | listen() | — | wires the app to the host; returns the App |

Notes:

  • Handlers are optional — omit any you don't need.
  • Passing a non-function (and non-null) to an on* method throws a TypeError immediately, so a typo is caught at startup rather than swallowed at the first event. Passing null/undefined clears the handler (useful for conditional registration).
  • data in onKey is the decoded input as a string: usually one character ("q"), but escape sequences arrive whole ("\x1b[A" = up arrow) and control keys as their codes ("\x03" = Ctrl-C, "\x04" = Ctrl-D, "\r" = Enter).

Conn — a terminal session

The object passed to every handler. One per connected user.

| Member | Type | Description | | --- | --- | --- | | conn.id | string (readonly) | Stable, unique session id assigned by the host (e.g. "c7"). Use it to key per-session state. | | conn.cols | number (readonly) | Current terminal width in columns. Updated before onResize fires. | | conn.rows | number (readonly) | Current terminal height in rows. Updated before onResize fires. | | conn.write(data) | (string) => void | Send a string to this terminal. Non-strings are coerced with String. | | conn.close() | () => void | End this SSH session. onClose still fires afterwards. |

write sends bytes verbatim — there is no implicit newline. Terminals need a carriage return to move to column 0, so end lines with \r\n, not \n:

conn.write('\x1b[2J\x1b[H');           // clear screen, cursor home
conn.write('\x1b[1;32mready\x1b[0m\r\n'); // bold green "ready" + newline

There is no buffering layer — every write is forwarded straight to the host. For redraw-heavy apps, build the frame in a string and write it once rather than many small writes, and prefer ANSI cursor moves over full-screen clears to reduce flicker.

fetch(url, opts)

Perform an HTTP(S) request. The request runs in the trusted host, not the sandbox — the app never touches a socket. Returns a Promise<FetchResult>.

const res = await fetch('https://api.example.com/items', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ name: 'widget' }),
});
// res.status  → number    e.g. 200
// res.headers → object     lower-cased header names → values
// res.body    → string     the response body as UTF-8 text
const item = JSON.parse(res.body);

Options (FetchOptions) — a JSON-serializable subset of the standard fetch init. Only these three fields cross into the host:

| Field | Type | Notes | | --- | --- | --- | | method | string | Defaults to GET. | | headers | Record<string,string> or [name, value][] | — | | body | string | Must be a string — options are JSON-serialized en route, so JSON.stringify your payload. |

Result (FetchResult): { status: number, headers: Record<string,string>, body: string }. The body is always returned as text — call JSON.parse yourself for JSON. There is no streaming; the full body (up to the size cap) is buffered and handed back at once.

It is guarded. Because the URL is app-controlled and runs from the host's network position, the host (server/safe-fetch.js) enforces, on every request and every redirect hop:

| Guard | Limit | | --- | --- | | Scheme allowlist | http: / https: only | | Address blocklist | private, loopback, link-local, CGNAT, multicast, and the 169.254.169.254 cloud-metadata range are refused — IP literals included, so a redirect to http://127.0.0.1 can't slip through | | DNS-rebinding safe | the host resolves + validates + pins the connection to the checked address | | Credential stripping | authorization / cookie / proxy-authorization are dropped on a cross-origin redirect | | Redirects | followed manually, up to 5 hops, each re-validated | | Timeout | 10 s for the whole request | | Response size | capped at 5 MiB; the body is truncated/aborted past it | | Rate limit | 30 requests per 10 s window, per app | | Concurrency | 6 in-flight requests, per app |

A blocked, timed-out, oversize, rate-limited, or failed request rejects with a sanitized Error (no host paths/stack). Always try/catch around fetch and show the user something useful (see the Quick start).


The lifecycle & events

The host calls the SDK once per event over a single channel (__term_emit(event, id, data); see the host contract). The SDK turns each event into a handler call:

| Event | data from host | What the SDK does | Your handler | | --- | --- | --- | --- | | connect | { cols, rows } | builds a Conn, stores it by id | onConnect(conn) | | key | decoded input string | looks up the Conn | onKey(conn, data) | | resize | { cols, rows } | updates conn.cols/conn.rows, then calls you | onResize(conn, size) | | close | null | calls you, then discards the Conn | onClose(conn) |

Ordering guarantees: connect is always first for a session; key/resize only arrive between connect and close; close is always last and the Conn is removed right after it. A key/resize for an unknown id (e.g. after close) is a no-op.


Terminal size

The terminal size travels on the same event channel — there is no separate bridge:

  • connect carries the initial { cols, rows }. conn.cols/conn.rows are set from it (falling back to 80x24 if ever absent).
  • resize carries the new { cols, rows } on each window-change. The SDK updates conn.cols/conn.rows before invoking onResize, so a handler that reads them — or calls a shared render(conn) — sees the post-resize size.
  • The host clamps incoming dimensions (SSH clients are untrusted): each axis is a positive integer, defaulting to 80/24 when bogus and capped at 1000. So you can treat conn.cols/conn.rows as sane without re-validating.
function render(conn) {
  const rule = '─'.repeat(conn.cols);   // always spans the current width
  conn.write('\x1b[2J\x1b[H' + rule + '\r\n');
}
createApp().onConnect(render).onResize(render).listen();

Error handling

The SDK draws a hard line so one app — or one buggy handler — can't take down the shared sandbox or the other users in it:

  • A synchronous throw inside any handler is caught by the SDK, logged via console.error('[voxal app error]', …), and swallowed. The session stays open.

  • An async handler's rejection after its first await escapes the SDK's try/catch (the SDK does not await your promise). Guard your own awaits:

    .onKey(async (conn, data) => {
      try {
        const res = await fetch(url);
        conn.write(res.body);
      } catch (err) {
        conn.write('error: ' + err.message + '\r\n'); // handle it yourself
      }
    })
  • console.log / warn / error are wired to the host and show up in the server logs (prefixed with the app name), not on the user's terminal. They're for you, the operator — use conn.write for anything the user should see.

  • On top of all this, the host wraps every event dispatch in its own CPU timeout and memory limit, so even an infinite loop is contained to the offending app.


The UI layer

@voxal/sdk/ui is the SDK's UI layer: build TUIs as React components with flexbox layout — 1-to-1 with Ink, the React-for-CLIs renderer. If you know Ink, you already know this; an Ink app ports over almost verbatim. You describe a component tree and it lays itself out and renders.

It is not an imitation — it runs the same engines Ink does:

  • Real React via react-reconciler — real hooks (useState/useEffect/useRef/…), real component model, real reconciliation. Verified to run inside the isolate on top of the host's setTimeout/queueMicrotask.
  • Real Yoga via yoga-wasm-web/asm — Facebook's flexbox engine compiled to pure JavaScript (asm.js — not WebAssembly, not a native module, so it satisfies the pure-JS rule). Layout is byte-identical to desktop Ink.
  • The SDK's own Unicode-correct text measurement, SGR/color, input parsing, and a cell-diff renderer live as internal modules under ui/internal/ — the substrate that Ink's own npm deps (string-width, chalk, yoga-layout, ...) can't provide inside the sandbox. They give correct emoji/CJK width, color downsampling, and flicker-free minimal-diff repaints.

No host-contract change. Everything goes through the existing byte channel and globals (_write, __term_emit, setTimeout). React, the reconciler, and Yoga-as-JS are all bundled into the app by voxal deploy (the CLI enables JSX and defines process.env.NODE_ENV). A minified app bundle is ~300 KB — well within the 5 MiB limit.

Verified parity with Ink 7.0.5. Parity is not just claimed, it is tested against real Ink. test/ink-differential.test.mjs renders 115+ component trees (borders, padding, every justifyContent/alignItems, gaps, percent widths, flex-grow/-basis/-shrink, row/column reverse, wrapping/truncation, tabs, trailing/boundary spaces, absolute positioning, wide CJK and emoji/ZWJ/flag/combining-mark width, nested styles, rgb/ansi256 colors, …) in our layer and asserts the result matches a golden fixture captured from the real [email protected] package — compared cell by cell, folding both outputs through one SGR decoder so glyph, position, color, and attributes must all agree. To make that exact: our text wrapper is a faithful port of wrap-ansi (Ink's wrapper — same boundary-space and hard-break behavior), and our truecolor→256→16 downsampling reproduces chalk/ansi-styles byte-for-byte. The default profile is truecolor, where no quantization happens and the bytes equal Ink's on a 24-bit terminal. (Regenerate the golden after an Ink bump per the header of that test file.)

Quick start

import React, { useState } from 'react';
import { serve, Box, Text, useInput, useApp } from '@voxal/sdk/ui';

function Counter() {
  const [n, setN] = useState(0);
  const { exit } = useApp();
  useInput((input, key) => {
    if (input === 'q') exit();
    if (key.upArrow) setN((c) => c + 1);
    if (key.downArrow) setN((c) => c - 1);
  });
  return (
    <Box borderStyle="round" padding={1} flexDirection="column">
      <Text>count: <Text bold color="green">{n}</Text></Text>
      <Text dimColor>↑/↓ to change · q to quit</Text>
    </Box>
  );
}

// Every `ssh app@host` connection gets its own <Counter/> (its own React tree + state).
serve(() => <Counter/>);

Write the entry as app.jsx (JSX is supported in .js too) and voxal deploy. react must be a dependency of your app (npm i react) — it's a peer dependency, exactly like Ink.

Rendering modes

| Mode | How | When | | --- | --- | --- | | inline (default) | a live region updated in place (cursor-up + erase), <Static> content scrolls into history above it | logs, chat, wizards — anything that should leave a transcript behind | | fullscreen | serve(fn, { fullscreen: true }) — the alternate screen + the Screen cell-diff renderer | games, dashboards, full-window apps |

Other options: exitOnCtrlC (default true), colors ('truecolor'/'256'/'16'/'none'), mouse (fullscreen only).

Components

All 1-to-1 with Ink:

  • <Box> — a flexbox container. Every layout prop is a flat prop: flexDirection (default 'row'), flexGrow/flexShrink/flexBasis, alignItems ('flex-start'/ 'center'/'flex-end'/'stretch'/'baseline')/alignSelf (those + 'auto')/ justifyContent/alignContent, width/height/min*/max* (number = cells, '50%' = percent, 'auto'), aspectRatio, padding*/margin*/gap/columnGap/rowGap, position ('relative'/'absolute'/'static') + top/right/bottom/left, display ('flex'/'none'), overflow/overflowX/overflowY ('visible'/'hidden'hidden clips children), backgroundColor (filled inside the border, never over the glyphs), and borders: borderStyle ('single'/'double'/'round'/'bold'/'singleDouble'/ 'doubleSingle'/'classic'/'arrow', or a custom glyph object), borderColor (+ per-edge borderTopColor etc.), per-edge toggles (borderTop={false}), borderDimColor (+ per-edge), and borderBackgroundColor (+ per-edge borderTopBackgroundColor etc.). aria-label/ aria-hidden/aria-role/aria-state are accepted and ignored (no screen reader over SSH).
  • <Text> — styled inline text: color, backgroundColor (inherits a parent <Box>'s via context), dimColor, bold, italic, underline, strikethrough, inverse, and wrap ('wrap' — word-wrap, hard-breaking over-long words | 'hard' — fill every column, always breaking words | 'truncate'/'truncate-end' | 'truncate-start' | 'truncate-middle'). Nest <Text> inside <Text> to compose styles. Colors accept names in either chalk ordering — 'red', 'redBright', 'brightRed', 'gray'/'grey' — plus hex ('#ff8800'), 'rgb(r,g,b)', and 'ansi256(n)'. (aria-label/aria-hidden accepted, ignored.)
  • <Newline count={n} />n line breaks (inside <Text>).
  • <Spacer/> — a flexible gap (<Box flexGrow={1}/>) that pushes siblings apart.
  • <Static items={[…]}>{(item, i) => …} — render items once, permanently, above the live region; they scroll into normal terminal scrollback and are never repainted (ideal for streaming logs / completed tasks — thousands of items stay cheap).
  • <Transform transform={(line, i) => string}> — post-process the rendered string of its <Text> children, one output line at a time (gradients, etc.). Nested inside another <Text> it transforms that child's flattened string (internal_transform is applied in the squash pass, so wrapping libraries like ink-link work). accessibilityLabel accepted/ignored.

Hooks

  • useInput((input, key) => …, { isActive }) — keyboard input. input is the typed character ('' for named keys; pasted text only if no usePaste is active); key is the boolean map { upArrow, downArrow, leftArrow, rightArrow, pageUp, pageDown, home, end, return, escape, tab, backspace, delete, ctrl, shift, meta, super, hyper, capsLock, numLock } (the last four are kitty-protocol modifiers — always false over a plain SSH PTY, present for shape parity). A single uppercase letter sets key.shift.
  • useApp(){ exit }. exit() resolves waitUntilExit(); exit(error) rejects it.
  • useStdin() / useStdout() / useStderr() — the connection surfaces. setRawMode and setBracketedPasteMode are no-ops (the SSH connection is always raw, paste always on); kept for API parity. useStdout().write writes raw bytes above the UI.
  • useFocus({ id, autoFocus, isActive }){ isFocused, focus }, and useFocusManager(){ focusNext, focusPrevious, focus, enableFocus, disableFocus, activeId }. Tab / Shift-Tab cycle focus in registration order; Esc blurs.
  • usePaste((text) => …, { isActive }) — fires once per paste with the whole pasted string. While active, paste content is delivered here on a separate channel and is NOT forwarded to useInput (Ink's contract); with no usePaste mounted, pastes fall back to useInput as a typed string.
  • useWindowSize(){ columns, rows }, re-rendering on terminal resize.
  • useCursor(){ setCursorPosition }. setCursorPosition({ x, y }) shows the terminal cursor at those coordinates (relative to the UI's top-left, for IME/text fields); undefined hides it. Cleared automatically on unmount.
  • useBoxMetrics(ref){ width, height, left, top, hasMeasured } for a <Box>, updating on every layout commit (resize, sibling/content/position changes) — the reactive superset of measureElement.
  • useAnimation({ interval, isActive }){ frame, time, delta, reset }, advancing every interval ms. Every useAnimation in the app shares ONE host timer (one isolate timer for all animations, not one per component).
  • useIsScreenReaderEnabled()boolean. Always false here (SSH exposes no screen-reader signal); present so Ink apps that branch on it compile and run.
  • measureElement(ref.current){ width, height } of a <Box> after layout (call from an effect/handler — layout runs after commit).

A complete chat app

import React, { useState } from 'react';
import { serve, Box, Text, Static, Spacer, useInput, useApp } from '@voxal/sdk/ui';

function Chat() {
  const [messages, setMessages] = useState([]);
  const [draft, setDraft] = useState('');
  const { exit } = useApp();

  useInput((input, key) => {
    if (key.return) {
      if (draft.trim() === '/quit') return exit();
      if (draft.trim()) setMessages((m) => [...m, { id: m.length, text: draft }]);
      setDraft('');
    } else if (key.backspace || key.delete) {
      setDraft((d) => d.slice(0, -1));
    } else if (input && !key.ctrl && !key.meta) {
      setDraft((d) => d + input);
    }
  });

  return (
    <>
      <Static items={messages}>
        {(m) => (
          <Box key={m.id}>
            <Text color="cyan" bold>you  </Text><Text>{m.text}</Text>
          </Box>
        )}
      </Static>
      <Box borderStyle="round" borderColor="gray" paddingX={1}>
        <Text color="cyan">❯ </Text><Text>{draft}</Text><Text inverse> </Text>
      </Box>
    </>
  );
}

serve(() => <Chat/>);

The transcript (<Static>) scrolls into history; the bordered input box redraws on each keystroke with a block cursor — the Claude-Code feel, in ~30 lines. A fuller version with a host-bridged fetch lives in examples/ui-chat.

The host contract

This is the only coupling between the SDK and the rest of voxal (Contract #1 in project.md). It is deliberately tiny — changing it means changing the SDK and server/runtime.js's bootstrap together. Keep it minimal and stable.

Host → SDK (globals the host injects into the sandbox):

| Global | Signature | Purpose | | --- | --- | --- | | _write | (id, str) | write str to connection id's terminal | | _fetch | (url, optsJson) → Promise<string> | run a guarded request in the host; resolves to JSON.stringify({ status, headers, body }) | | _close | (id) | end connection id's SSH session (optional — the SDK guards its absence) |

SDK → host (the one global the SDK installs):

| Global | Signature | Purpose | | --- | --- | --- | | globalThis.__term_emit | (event, id, data) | the host calls this for every event |

__term_emit events and their data:

| event | data | | --- | --- | | 'connect' | { cols, rows } — the initial terminal size | | 'key' | the decoded input as a string | | 'resize' | { cols, rows } — the new terminal size | | 'close' | null |

listen() is what assigns globalThis.__term_emit. The host looks it up immediately after running the bundle; if it's missing, the app is considered to have "never called listen()" and the boot fails. (fetch's argument/result marshalling — copy-in, promise-out — is handled by the host's bridge; the SDK just passes strings.)


The runtime environment

App code (your bundle + this SDK) runs inside a per-app isolated-vm Isolate — its own V8 heap and event loop, not Node's vm. That gives a real security boundary plus per-app limits the host enforces:

| Limit | Value | Meaning | | --- | --- | --- | | Memory | 128 MiB per app | hitting it aborts the running script (contained to that app) | | CPU per burst | 500 ms (1 s at boot) | one uninterrupted synchronous run — an await yields and does not count | | Live timers | 1024 per app | setTimeout/setInterval beyond this throw | | Min timer delay | 4 ms | shorter delays are clamped (no 0 ms spin) | | Idle drop | 5 min | an app with zero connections is torn down (scale-to-zero); it reboots on the next connect |

Globals available inside the isolate:

  • All standard ECMAScript / V8 built-ins: Object, Array, Map, Set, JSON, Math, Date, Promise, RegExp, typed arrays, Intl, etc. This includes Intl.Segmenter and Unicode property escapes (\p{RGI_Emoji} with the v flag) — which is what lets the UI layer measure emoji and grapheme widths correctly inside the isolate.
  • Injected by the host bootstrap: console (log/info/warn/error/debug → server logs), setTimeout/setInterval/clearTimeout/clearInterval (host-scheduled, capped as above), queueMicrotask, and the low-level _write/_fetch/_close bridges the SDK wraps.

Not available (so write pure JS and use the SDK):

  • No Node built-ins (fs, net, crypto, process, Buffer, require, …).
  • No native npm modules — the bundler ships pure JS only. (The UI layer's Yoga is yoga-wasm-web/asm — Yoga compiled to pure JavaScript, not WebAssembly or a native addon — and react/react-reconciler are pure JS, so the rule holds. They run on the isolate's setTimeout/queueMicrotask/Intl.Segmenter/WebAssembly-free globals.)
  • No platform Web APIs that V8 doesn't provide on its own: URL, URLSearchParams, TextEncoder/TextDecoder, atob/btoa, and the global fetch are absent. Use the SDK's fetch, and parse/format by hand (or bundle a pure-JS helper).

Validation matches runtime. The CLI's local boot-check (run during voxal deploy, see the pipeline) boots the bundle in a real isolated-vm isolate with the same BOOTSTRAP and host primitives as the production server (cli/lib/project.jsvalidateBundle). So "validates locally" genuinely means "will run in prod": no URL/TextEncoder/etc. are injected that the real isolate lacks. If you need a Web API the isolate doesn't provide, bundle a pure-JS implementation — don't rely on the host to add it. (To make a new global available everywhere, add it to BOOTSTRAP in both server/runtime.js and cli/lib/project.js, which are deliberate copies of each other.)


The build & deploy pipeline

The SDK has no build step of its own. It is bundled into each app by the CLI:

  1. Bundlevoxal deploy runs esbuild over the app's entry (app.js/app.jsx) plus @voxal/sdk and any pure-JS deps into one self-contained file: format: "iife", platform: "neutral", target: "es2022", charset: "utf8", minify: true. One IIFE, no imports, no Node globals — exactly what the isolate runs. (Minification only mangles names; host-side error logs show the mangled form.) The bundler also enables the JSX transform (jsx: "automatic", JSX accepted in .js) and defines process.env.NODE_ENV = "production" — both required by the UI layer's React/react-reconciler (which reads process.env, absent in the isolate); plain non-React apps are unaffected (no JSX → React isn't pulled in).
  2. Boot-check — the CLI runs the bundle in a throwaway sandbox that mirrors the runtime and asserts it installs __term_emit (i.e. that the app called listen()). A top-level throw or a missing listen() fails the deploy locally, before anything ships. (See the gotcha above about this sandbox being slightly more permissive than the real isolate.)
  3. Size gate — bundles are capped at 5 MiB (a warning past 4 MiB).
  4. Deploy — the CLI POSTs { name, code, sha256 } to the server, which re-authorizes ownership and (in R2 mode) forwards the bytes to the dashboard Worker → Cloudflare R2, hash-verified end to end. On a cold connect the server pulls the bundle back, re-verifies the hash, runs it in a fresh isolate, and caches it for an hour.

Because the SDK is tree-shakeable ("sideEffects": false, no import-time side effects), unused exports are dropped from the bundle. Practically the whole SDK is tiny, but the metadata keeps it honest.

Since the SDK is consumed only by being bundled, anything it does at import time must be a no-op — and it is: index.js only declares functions and exports. globalThis.__term_emit is assigned inside listen(), at the app's runtime, never at import.


Types

index.d.ts ships with the package ("types" in package.json) and is the source of truth for consumers. Exposed:

  • createApp(): App and fetch(url, opts?): Promise<FetchResult>
  • Interfaces: App, Conn, Size, FetchOptions, FetchResult
  • Handler aliases: ConnectHandler, KeyHandler, ResizeHandler, CloseHandler
  • HttpMethod — the common verbs as literals, while still accepting any string

index.js carries JSDoc mirroring these, so plain-JS users get hover docs and examples in their editor without a TS setup. Keep the two in sync when changing the surface. The optional layers ship their own types: ui.d.ts (@voxal/sdk/ui) and ink.d.ts (@voxal/sdk/uirender/serve, BoxProps/TextProps, Key, the hooks), each wired up under the matching exports subpath in package.json.


Stability & versioning

  • Public API (createApp, the on*/listen builder, Conn, fetch, FetchResult): semver. Breaking it is a major bump and a docs update here.
  • The host contract (_write/_fetch/_close and the __term_emit event shapes): changing it requires a coordinated change in server/runtime.js. Treat it as a frozen wire format; add, don't repurpose.
  • The package is pre-1.0 (0.x). The scaffolder (create-voxal-app) and examples/ pin @voxal/sdk — bump them together with any version change so a fresh npm create voxal-app keeps resolving.

Developing the SDK

There is nothing to compile. To sanity-check a change end to end:

# 1. unit-level: it should bundle and install __term_emit
cd examples/hello && npm i
npx esbuild app.js --bundle --format=iife --platform=neutral \
  --target=es2022 --minify | node --input-type=module -e '
    let installed = false;
    globalThis._write = () => {};
    globalThis._close = () => {};
    globalThis._fetch = async () => JSON.stringify({status:0,headers:{},body:""});
    process.stdin.resume();
  ' # or just run `voxal deploy`, which does the bundle + boot-check for you

# 2. integration: the real loop
cd server && npm i && npm start          # SSH :2222, deploy API :8787
voxal deploy                             # from the app dir
ssh hello@localhost -p 2222              # exercise connect/key/resize/close

The fastest real check is voxal deploy (it bundles + boot-checks) followed by an ssh session that exercises connect → keys → resize → q/Ctrl-C close. When you touch the host contract, update server/runtime.js's BOOTSTRAP and the CLI's boot-check sandbox (cli/lib/project.js) in the same change, and re-run the ssh loop — those are the two places that must agree with this file.

Guardrails when editing index.js:

  • It must stay pure ES2022 with zero imports and no import-time side effects (so it bundles to a clean IIFE and tree-shakes).
  • Don't widen the host contract casually — every new global is a new thing the host must inject and keep forever.
  • Keep handler dispatch wrapped so an app throw can't escape into the shared sandbox.

File layout

sdk/
├── index.js       core SDK (createApp, fetch) — zero deps, runs inside the isolate
├── index.d.ts     core TypeScript types (the published, authoritative surface)
├── ui/            the UI layer (@voxal/sdk/ui) — React + flexbox, 1-to-1 with Ink — see "The UI layer"
│   ├── yoga.js        Yoga (yoga-wasm-web/asm) loader + Ink style → Yoga setters
│   ├── measure-text.js  text measurement + wrapping for the layout pass
│   ├── colors.js      color specs → cell {fg,bg,attrs}
│   ├── squash.js      flatten a <Text> subtree to one SGR-styled string
│   ├── dom.js         internal node model (ink-root/box/text/virtual-text/#text)
│   ├── reconciler.js  react-reconciler host config (React → dom nodes)
│   ├── output.js      Output cell buffer (clip stack, wide chars, tab→8-col expansion, getRows)
│   ├── render-border.js     box backgrounds + borders (cli-boxes glyphs)
│   ├── render-node-to-output.js  walk the laid-out tree → paint into Output
│   ├── contexts.js    React contexts (App/Stdin/Stdout/Stderr/Focus/background/WindowSize/Accessibility/Cursor/Animation)
│   ├── app-component.js  <App> provider stack + focus engine + window-size + ErrorBoundary
│   ├── components.js  Box/Text/Newline/Spacer/Static/Transform
│   ├── hooks.js       useApp/useStdin/useStdout/useStderr/useInput/useFocus/useFocusManager/
│   │                  measureElement/useWindowSize/usePaste/useCursor/useBoxMetrics/useAnimation/useIsScreenReaderEnabled
│   ├── ink.js         the per-connection renderer (Yoga layout + flush + input + anim timer + cursor)
│   ├── render.js      render(node, conn) + serve(factory) entry points
│   ├── index.js       barrel re-export (the public @voxal/sdk/ui surface)
│   └── internal/      substrate the layer is built on (NOT public API)
│       ├── text.js        Unicode width (wcwidth/UAX#11/#29) + ANSI-aware wrap/truncate/sanitize;
│       │                  `wrap` is a faithful port of wrap-ansi (byte-identical line breaks)
│       ├── style.js       colors + attributes → SGR; truecolor→256→16 downsampling matches
│       │                  chalk/ansi-styles byte-for-byte (rgbToAnsi256 + ansi256ToAnsi)
│       ├── keys.js        InputParser: raw bytes → key/paste events
│       ├── layout.js      constraint helpers used by screen.js
│       └── screen.js      cell-diff renderer (drives fullscreen mode)
├── ui.d.ts        UI-layer TypeScript types (render/serve, Box/Text props, Key, all hooks)
├── test/          node:test suites:
│   ├── ui.test.mjs           unit tests + a real-isolate bundle boot
│   ├── ui-parity.test.mjs    every audited fix/hook + a real-isolate boot of the new hooks
│   ├── ink-differential.test.mjs  renders each case in ours and asserts it matches REAL Ink
│   ├── ink-cases.mjs              library-agnostic render cases (shared by the oracle + ours)
│   └── fixtures/ink-7.0.5-golden.json   exact frames from real [email protected] (the parity oracle)
├── THIRD_PARTY_NOTICES.md   attribution for Ink / React / Yoga (all MIT)
├── package.json   ESM, sideEffects:false, exports `.`/`./ui`; deps: react-reconciler +
│                  yoga-wasm-web (ui only), react (peer), esbuild/isolated-vm (dev)
└── README.md      this file

sdk/ is its own git repo and an independent codebase: no shared deps and no cross-folder imports with server/, cli/, etc. The only thing that ties them together is the host contract above. The two public subpaths are tree-shakeable: @voxal/sdk (core) stays zero-dependency, and an app pays for @voxal/sdk/ui's React/Yoga only if it imports it.