voxal docs
SDK · Core

createApp and lifecycle

Create an app, register connect, key, resize, and close handlers, then call listen

createApp() returns a chainable app that you configure with event handlers and then start with .listen(). Nothing runs until you call listen(), which is what wires the app to the host.

import { createApp } from '@voxalsh/sdk';

createApp()
  .onConnect(handleConnect)
  .onKey(handleKey)
  .onResize(handleResize)
  .onClose(handleClose)
  .listen();

Each on* method returns the app, so calls chain, and every handler may be async. One app instance handles every connected user (they are all multiplexed into it), so keep per-user state keyed by conn.id.

A minimal app

app.js
import { createApp } from '@voxalsh/sdk';

createApp()
  .onConnect((conn) => {
    conn.write('\x1b[2J\x1b[H'); // clear screen, home cursor
    conn.write('hello from voxal\r\n');
    conn.write('press q to quit\r\n');
  })
  .onKey((conn, data) => {
    if (data === 'q' || data === '\x03') return conn.close();
    conn.write('you pressed: ' + JSON.stringify(data) + '\r\n');
  })
  .onClose((conn) => {
    console.log('bye', conn.id); // server logs
  })
  .listen();

Handlers

Pass each on* method a function. Passing null or undefined clears that handler, which is handy for conditional registration. Passing any other non-function throws a TypeError at registration, so mistakes surface immediately instead of at the first event.

onConnect(handler)

onConnect(handler: (conn: Conn) => void | Promise<void>): App

Fires when a user connects. This is the first event, so use it to build and seed per-connection state and to do the first render. conn.cols and conn.rows already hold the terminal size.

.onConnect((conn) => {
  conn.write('welcome, your terminal is ' + conn.cols + 'x' + conn.rows + '\r\n');
})

onKey(handler)

onKey(handler: (conn: Conn, data: string) => void | Promise<void>): App

Fires on input. data is the decoded input string, usually a single character, but escape sequences and control codes arrive too. See The connection for the full encoding.

.onKey((conn, data) => {
  if (data === 'q' || data === '\x03') return conn.close();
  if (data === '\x1b[A') return conn.write('up\r\n');
  conn.write('you pressed ' + JSON.stringify(data) + '\r\n');
})

Tip

If you are matching arrow keys, control codes, and editing keys by hand, consider the UI layer's useInput. It decodes all of that into a friendly key object for you.

onResize(handler)

onResize(handler: (conn: Conn, size: Size) => void | Promise<void>): App

Fires on terminal resize. conn.cols and conn.rows are already updated before your handler runs, and the new size is also passed as { cols, rows }.

.onResize((conn, size) => {
  conn.write('\x1b[2J\x1b[H');               // clear and home
  conn.write('-'.repeat(size.cols) + '\r\n'); // redraw to fit
})

onClose(handler)

onClose(handler: (conn: Conn) => void | Promise<void>): App

Fires when the user disconnects, whether they dropped the session or your app called conn.close(). Clean up per-connection state here.

const sessions = new Map();

createApp()
  .onConnect((conn) => sessions.set(conn.id, { score: 0 }))
  .onClose((conn) => sessions.delete(conn.id))
  .listen();

listen()

listen(): App

Activates the app. Call it exactly once, after registering handlers. Calling listen() on a second app within one bundle logs a warning and the last one wins, since only one app per bundle is supported.

Error handling

The SDK does not await your handlers, so events are not ordered relative to your own async work. Guard your own awaits with try/catch.

Thrown errors and rejected promises from your handlers are caught and logged to the server logs (prefixed [voxal app error]), not the user's terminal, so one bad handler can never crash the shared sandbox. To show the user something, write to the terminal yourself.

Managing per-connection state

All users of your app share one sandbox, so keep per-session data in a Map keyed by conn.id, and free it in onClose.

const state = new Map();

createApp()
  .onConnect((conn) => {
    state.set(conn.id, { count: 0 });
    render(conn);
  })
  .onKey((conn, data) => {
    const s = state.get(conn.id);
    if (data === '+') s.count++;
    render(conn);
  })
  .onClose((conn) => state.delete(conn.id))
  .listen();

function render(conn) {
  const { count } = state.get(conn.id);
  conn.write('\x1b[2J\x1b[Hcount: ' + count + '\r\n');
}

Module-level values like state above are shared across all connections and reset when the app scales to zero and re-boots. See How voxal works for the details.

On this page