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.
Setting it up
Section titled “Setting it up”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.
Declaring commands and events
Section titled “Declaring commands and events”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(¬e.path, format!("{}.bak", note.path)).map_err(fail)?; } let tags = note.tags.join(", "); std::fs::write(¬e.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.
Generating the file
Section titled “Generating the file”sabine bindingssabine 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.
The generated file
Section titled “The generated file”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.
How Rust types appear in TypeScript
Section titled “How Rust types appear in TypeScript”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.
Types no command names
Section titled “Types no command names”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>()What the bindings leave out
Section titled “What the bindings leave out”- Commands registered with
bridge_handler,bridge_handler_asyncand the descriptor variants. Call them withinvoke. - Events sent with
emit,emit_to,emit_bytesoremit_bytes_toby name, without aBridgeEvent. Listen for them withlisten. - Sabine’s own commands and events, which the
@lantharos/sabinepackage already types. See the JavaScript API.
Keeping the file checked in
Section titled “Keeping the file checked in”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:
sabine bindings --checkWith --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.