Skip to main content

Overview

Rezi’s runtime is backend-agnostic. You can implement custom backends for different platforms (embedded, web, FFI, etc.) by implementing the RuntimeBackend interface.

RuntimeBackend Interface

import type { RuntimeBackend, BackendEventBatch, TerminalCaps } from "@rezi-ui/core";

const myBackend: RuntimeBackend = {
  async start() {
    // Initialize engine, enter raw mode
  },

  async stop() {
    // Exit raw mode, cleanup
  },

  dispose() {
    // Release all resources (idempotent)
  },

  async requestFrame(drawlist: Uint8Array) {
    // Submit ZRDL frame to engine
  },

  async pollEvents(): Promise<BackendEventBatch> {
    // Poll for next event batch (ZREV format)
    return {
      bytes: new Uint8Array(),
      droppedBatches: 0,
      release: () => {},
    };
  },

  postUserEvent(tag: number, payload: Uint8Array) {
    // Post user event (thread-safe)
  },

  async getCaps(): Promise<TerminalCaps> {
    // Return terminal capabilities
    return DEFAULT_TERMINAL_CAPS;
  },

  async getProfile() {
    // Optional: Return detailed terminal profile
    return undefined;
  },
};

Required Methods

start()

Returns
Promise<void>
Initialize backend and enter raw mode.
async start() {
  // 1. Initialize terminal/engine
  await this.engine.init();

  // 2. Enter raw mode
  await this.engine.setRawMode(true);

  // 3. Enable mouse tracking, alternate screen, etc.
  await this.engine.enableFeatures();
}

stop()

Returns
Promise<void>
Exit raw mode and cleanup.
async stop() {
  // 1. Exit raw mode
  await this.engine.setRawMode(false);

  // 2. Restore terminal state
  await this.engine.restore();
}

dispose()

Returns
void
Release all resources. Must be idempotent.
dispose() {
  if (this.disposed) return;
  this.disposed = true;

  // Clean up resources
  this.engine?.dispose();
  this.eventQueue?.clear();
}

requestFrame()

drawlist
Uint8Array
required
ZRDL drawlist bytes to render.
Returns
Promise<void>
Resolves when frame is submitted (may not be rendered yet).
async requestFrame(drawlist: Uint8Array) {
  // Send drawlist to engine
  await this.engine.submitFrame(drawlist);
}
The drawlist bytes must not be modified. Clone if you need to store them.

pollEvents()

Returns
Promise<BackendEventBatch>
Next event batch in ZREV v1 format.
async pollEvents(): Promise<BackendEventBatch> {
  // Wait for next event batch
  const batch = await this.eventQueue.next();

  return {
    bytes: batch.data, // ZREV v1 bytes
    droppedBatches: batch.dropped,
    release: () => {
      // Return buffer to pool
      this.bufferPool.release(batch.data.buffer);
    },
  };
}
The runtime calls release() exactly once per batch. Do not reuse the buffer until release() is called.

postUserEvent()

tag
number
required
User-defined event tag.
payload
Uint8Array
required
Arbitrary payload bytes.
postUserEvent(tag: number, payload: Uint8Array) {
  // Queue user event (thread-safe)
  this.eventQueue.push({ tag, payload });

  // Wake event poller if blocked
  this.engine.wakePoller();
}

getCaps()

Returns
Promise<TerminalCaps>
Terminal capability snapshot.
import { DEFAULT_TERMINAL_CAPS, COLOR_MODE_RGB } from "@rezi-ui/core";

async getCaps(): Promise<TerminalCaps> {
  return {
    ...DEFAULT_TERMINAL_CAPS,
    colorMode: COLOR_MODE_RGB,
    supportsMouse: true,
    supportsOsc52: true,
  };
}

Optional Markers

Backends can expose optional markers:

Drawlist Version

import { BACKEND_DRAWLIST_VERSION_MARKER } from "@rezi-ui/core";

backend[BACKEND_DRAWLIST_VERSION_MARKER] = 5; // ZRDL v5

FPS Cap

import { BACKEND_FPS_CAP_MARKER } from "@rezi-ui/core";

backend[BACKEND_FPS_CAP_MARKER] = 60;

Max Event Bytes

import { BACKEND_MAX_EVENT_BYTES_MARKER } from "@rezi-ui/core";

backend[BACKEND_MAX_EVENT_BYTES_MARKER] = 8192;

Raw Write

import { BACKEND_RAW_WRITE_MARKER, type BackendRawWrite } from "@rezi-ui/core";

const writeRaw: BackendRawWrite = (text: string) => {
  process.stdout.write(text);
};

backend[BACKEND_RAW_WRITE_MARKER] = writeRaw;

Example: Minimal Backend

import { createApp, DEFAULT_TERMINAL_CAPS, type RuntimeBackend } from "@rezi-ui/core";

class MinimalBackend implements RuntimeBackend {
  private eventQueue: Uint8Array[] = [];

  async start() {
    console.log("Backend started");
  }

  async stop() {
    console.log("Backend stopped");
  }

  dispose() {
    this.eventQueue = [];
  }

  async requestFrame(drawlist: Uint8Array) {
    console.log(`Frame: ${drawlist.length} bytes`);
  }

  async pollEvents() {
    // Block until event available
    while (this.eventQueue.length === 0) {
      await new Promise(resolve => setTimeout(resolve, 16));
    }

    const bytes = this.eventQueue.shift()!;
    return {
      bytes,
      droppedBatches: 0,
      release: () => {},
    };
  }

  postUserEvent(tag: number, payload: Uint8Array) {
    // Encode as ZREV and push to queue
    this.eventQueue.push(payload);
  }

  async getCaps() {
    return DEFAULT_TERMINAL_CAPS;
  }
}

const app = createApp({
  backend: new MinimalBackend(),
  initialState: {},
});

Protocol References

ZRDL (Drawlist)

Drawlist binary format. See docs/protocol/zrdl.md. Versions: v1 (legacy), v3 (stable), v5 (latest)

ZREV (Events)

Event batch binary format. See docs/protocol/zrev.md. Current version: v1

Testing Backends

import { createApp, ui } from "@rezi-ui/core";
import { describe, test, assert } from "node:test";

describe("MyBackend", () => {
  test("starts and stops", async () => {
    const backend = new MyBackend();
    const app = createApp({ backend, initialState: {} });

    app.view(() => ui.text("Hello"));

    // Start and stop
    const runPromise = app.run();
    await new Promise(resolve => setTimeout(resolve, 100));
    app.stop();
    await runPromise;

    assert(true);
  });
});

@rezi-ui/node

Reference backend implementation

Terminal Caps

Capability detection

Build docs developers (and LLMs) love