Skip to content

Typed bindings

Sabine writes a TypeScript module for the commands and events your app registers. The page imports it instead of calling commands by name, so a renamed command, a missing field or a changed result type becomes a type error in the page rather than a failure at run time.

Name the file to write under [bindings] in Sabine.toml. The path is relative to Sabine.toml:

[bindings]
path = "ui/src/bindings.ts"

The generated module imports @lantharos/sabine, so the page depends on that package. Projects made with sabine new have both set up.

Commands registered with bridge_typed or bridge_typed_async go into the bindings, and so do events declared with bridge_event. Their params, results and payloads derive specta::Type next to serde’s derives:

use sabine::prelude::*;
use sabine::BridgeEvent;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Deserialize, Type)]
#[serde(rename_all = "camelCase")]
struct SaveNote {
path: String,
keep_backup: Option<bool>,
#[serde(default)]
tags: Vec<String>,
}
#[derive(Serialize, Type)]
struct Saved {
bytes: u64,
}
#[derive(Deserialize, Type)]
struct ListNotes {}
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase", tag = "type")]
enum Kind {
Text,
Checklist { items: u32 },
}
#[derive(Serialize, Type)]
struct Note {
id: u64,
title: String,
kind: Kind,
}
const SAVED: BridgeEvent<Saved> = BridgeEvent::new("notes.saved");
fn main() {
SabineWindow::main(|window| {
Ok(window
.app()
.bridge_typed("notes.save", save_note)
.bridge_typed("notes.list", list_notes)
.bridge_event(&SAVED))
});
}
fn save_note(note: SaveNote) -> Result<Saved, BridgeError> {
let fail = |error: std::io::Error| BridgeError::new(format!("could not save {}: {error}", note.path));
if note.keep_backup.unwrap_or(false) {
std::fs::copy(&note.path, format!("{}.bak", note.path)).map_err(fail)?;
}
let tags = note.tags.join(", ");
std::fs::write(&note.path, &tags).map_err(fail)?;
Ok(Saved {
bytes: tags.len() as u64,
})
}
fn list_notes(_: ListNotes) -> Result<Vec<Note>, BridgeError> {
Ok(vec![Note {
id: 1,
title: "Groceries".into(),
kind: Kind::Checklist { items: 4 },
}])
}

BridgeEvent::new is const, so an event’s name and payload type live in one constant that both bridge_event and the code sending the event use. Events covers sending them.

Terminal window
sabine bindings

sabine bindings builds the app and runs it with a request to write its bindings. The app writes the file as it reaches launch and exits before it opens a window, so this needs no display or Chromium runtime. Code in main before SabineWindow::main still runs, so keep side effects behind the builder.

sabine dev writes the bindings the same way before it starts the page, so the page’s dev server always sees the current file. It skips this with --web-only, and with --command, which runs a command of your own instead of the app. Run sabine bindings after changing commands while sabine dev is already running.

Sabine leaves the file untouched when its contents haven’t changed.

For the app above, Sabine writes:

// Generated by Sabine from the app's bridge commands and events. Run `sabine bindings` after changing them.
import { invoke, listen, type CallOptions } from "@lantharos/sabine";
export const commands = {
notesList: (options?: CallOptions) => invoke<Note[]>("notes.list", {}, options),
notesSave: (params: {
path: string,
keepBackup?: boolean | null,
tags?: string[],
}, options?: CallOptions) => invoke<Saved>("notes.save", params, options),
};
export const events = {
notesSaved: (callback: (payload: Saved) => void) => listen<Saved>("notes.saved", callback),
};
export type Kind = { type: "text" } | { type: "checklist"; items: number };
export type Note = {
id: number,
title: string,
kind: Kind,
};
export type Saved = {
bytes: number,
};

Each command is a function in commands that takes its params and CallOptions, the invoke options that apply to typed commands: signal and timeoutMs. Typed commands don’t take or answer with bytes; register a raw handler for those. Each event is a function in events that registers a listener and returns a function that removes it.

import { commands, events as appEvents } from "./bindings";
const notes = await commands.notesList();
const { bytes } = await commands.notesSave({ path, keepBackup: true }, { timeoutMs: 10_000 });
const stop = appEvents.notesSaved(({ bytes }) => showSaved(bytes));
stop();

The package @lantharos/sabine exports an events object of its own for Sabine’s built-in events, so import one of the two under another name when a module uses both.

The bindings follow serde: rename_all, rename, tag, skip, default and the other serde attributes shape the TypeScript types exactly as they shape the JSON.

Rust TypeScript
String, &str, char string
bool boolean
Every integer and float type number
Option<T> T | null; a params field may also be left out
Vec<T>, slices T[]
A field with #[serde(default)] in params an optional field
() result null
sabine::Bytes event payload Uint8Array
Structs and enums named types, shaped by serde

Numbers of every width are number, because that is what the page’s JSON parser makes of them. Integers beyond 2⁵³ lose precision in the page; send such values as strings.

A command whose params type has no fields, such as struct ListNotes {}, gets a function without a params argument. Use an empty struct like this for commands that take nothing.

Params types appear inline in the command’s signature. A type that is both sent and accepted appears once, under its own name, in the shape the page receives; params read as the app accepts them, so a field with a default may be left out there.

Command names become camelCase keys: notes.save is notesSave and sync-status.get is syncStatusGet. When two names would become the same key, sabine bindings fails and names both.

A package of page code shared between apps may use a type that no command or event of this app mentions. Add it with bridge_type:

window.bridge_type::<Theme>()
  • Commands registered with bridge_handler, bridge_handler_async and the descriptor variants. Call them with invoke.
  • Events sent with emit, emit_to, emit_bytes or emit_bytes_to by name, without a BridgeEvent. Listen for them with listen.
  • Sabine’s own commands and events, which the @lantharos/sabine package already types. See the JavaScript API.

Check the bindings file into version control with the rest of the page. The page’s type checker and bundler then work without a Rust toolchain, and a change to a command shows up in review as a change to its TypeScript signature.

In CI, check that the file is current:

Terminal window
sabine bindings --check

With --check, Sabine renders the bindings and compares them with the file without writing it. When they differ, it exits with an error that names the file and asks you to run sabine bindings.