@voxalsh/sdk
v0.1.0
Published
SDK for building voxal interactive terminal apps reachable over SSH
Downloads
88
Maintainers
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).
- its types (
- 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;reactis 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 deploytime. - The only coupling to the rest of the system is the host contract; keep that surface tiny and stable.
Contents
- Where this sits
- Quick start
- The mental model
- API reference
- The lifecycle & events
- Terminal size
- Error handling
- The UI layer
- The host contract
- The runtime environment
- The build & deploy pipeline
- Types
- Stability & versioning
- Developing the SDK
- File layout
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 sandboxThe 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 itA 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 aMap) or in a closure, and free it inonClose. - 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 anon*method throws aTypeErrorimmediately, so a typo is caught at startup rather than swallowed at the first event. Passingnull/undefinedclears the handler (useful for conditional registration). datainonKeyis 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" + newlineThere 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:
connectcarries the initial{ cols, rows }.conn.cols/conn.rowsare set from it (falling back to80x24if ever absent).resizecarries the new{ cols, rows }on each window-change. The SDK updatesconn.cols/conn.rowsbefore invokingonResize, so a handler that reads them — or calls a sharedrender(conn)— sees the post-resize size.- The host clamps incoming dimensions (SSH clients are untrusted): each axis is a
positive integer, defaulting to
80/24when bogus and capped at1000. So you can treatconn.cols/conn.rowsas 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
awaitescapes the SDK'stry/catch(the SDK does not await your promise). Guard your ownawaits:.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/errorare 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 — useconn.writefor 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'ssetTimeout/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'—hiddenclips 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-edgeborderTopColoretc.), per-edge toggles (borderTop={false}),borderDimColor(+ per-edge), andborderBackgroundColor(+ per-edgeborderTopBackgroundColoretc.).aria-label/aria-hidden/aria-role/aria-stateare 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, andwrap('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-hiddenaccepted, ignored.)<Newline count={n} />—nline 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_transformis applied in the squash pass, so wrapping libraries like ink-link work).accessibilityLabelaccepted/ignored.
Hooks
useInput((input, key) => …, { isActive })— keyboard input.inputis the typed character (''for named keys; pasted text only if nousePasteis active);keyis 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 — alwaysfalseover a plain SSH PTY, present for shape parity). A single uppercase letter setskey.shift.useApp()→{ exit }.exit()resolveswaitUntilExit();exit(error)rejects it.useStdin()/useStdout()/useStderr()— the connection surfaces.setRawModeandsetBracketedPasteModeare no-ops (the SSH connection is always raw, paste always on); kept for API parity.useStdout().writewrites raw bytes above the UI.useFocus({ id, autoFocus, isActive })→{ isFocused, focus }, anduseFocusManager()→{ 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 touseInput(Ink's contract); with nousePastemounted, pastes fall back touseInputas 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);undefinedhides 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 ofmeasureElement.useAnimation({ interval, isActive })→{ frame, time, delta, reset }, advancing everyintervalms. EveryuseAnimationin the app shares ONE host timer (one isolate timer for all animations, not one per component).useIsScreenReaderEnabled()→boolean. Alwaysfalsehere (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 includesIntl.Segmenterand Unicode property escapes (\p{RGI_Emoji}with thevflag) — 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/_closebridges 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 — andreact/react-reconcilerare pure JS, so the rule holds. They run on the isolate'ssetTimeout/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 globalfetchare absent. Use the SDK'sfetch, 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 realisolated-vmisolate with the sameBOOTSTRAPand host primitives as the production server (cli/lib/project.js→validateBundle). So "validates locally" genuinely means "will run in prod": noURL/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 toBOOTSTRAPin bothserver/runtime.jsandcli/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:
- Bundle —
voxal deployruns esbuild over the app's entry (app.js/app.jsx) plus@voxal/sdkand 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 definesprocess.env.NODE_ENV = "production"— both required by the UI layer's React/react-reconciler(which readsprocess.env, absent in the isolate); plain non-React apps are unaffected (no JSX → React isn't pulled in). - 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 calledlisten()). A top-level throw or a missinglisten()fails the deploy locally, before anything ships. (See the gotcha above about this sandbox being slightly more permissive than the real isolate.) - Size gate — bundles are capped at 5 MiB (a warning past 4 MiB).
- 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(): Appandfetch(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/ui — render/serve, BoxProps/TextProps, Key, the hooks),
each wired up under the matching exports subpath in package.json.
Stability & versioning
- Public API (
createApp, theon*/listenbuilder,Conn,fetch,FetchResult): semver. Breaking it is a major bump and a docs update here. - The host contract (
_write/_fetch/_closeand the__term_emitevent shapes): changing it requires a coordinated change inserver/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) andexamples/pin@voxal/sdk— bump them together with any version change so a freshnpm create voxal-appkeeps 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/closeThe 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 filesdk/ 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.
