API overview
API overview, TypeScript
Generated TypeScript is strict-mode ESM with no any in exported types: plain interfaces and unions for data, promises for calls, signals for state, and hooks for React. The core package has no runtime dependencies.
undra bindgen writes an npm package (src/*.ts, package.json, tsconfig.json) that imports only from @undra/runtime. The shapes below come from the playground's generated code and the specification (§10.3).
Reference. Every declaration the playground generates, with its docs, is in the TypeScript reference.
Loading the core#
Load the core once, before you create a store, through the entry generated for it: Undra and the core's namespace ([core] namespace in undra.toml, by default the core's package name), here UndraPlaygroundCore. It compares the core's schema hash with the one the bindings were generated from and rejects a mismatch with UndraSchemaMismatchError.
import { UndraPlaygroundCore, Todos } from "@playground/core";
import wasmUrl from "../../build/web/playground_core.wasm?url";
await UndraPlaygroundCore.load({
mode: "wasm-main", // the core runs in a wasm module on this thread
wasm: new URL(wasmUrl, location.href),
adapters: { http, kv }, // optional: replace a default adapter
});
const todos = await Todos.create(); // uses UndraPlaygroundCore.coremode | Where the core runs |
|---|---|
"wasm-main" | a wasm module on the page's main thread |
"wasm-worker" | the same module in a worker, keeping the main thread free (synchronous ports run in the worker: Workers & crash recovery) |
"remote" | the core that undra dev serves, over a WebSocket: { mode: "remote", url: "ws://127.0.0.1:7443" } |
Before the core is loaded, or after it was closed, UndraPlaygroundCore.core, the default core of every generated constructor and function, is a closed placeholder: constructors and calls on it reject with UndraCallError.Unavailable. The module is named after the namespace too, so two cores load side by side.
Type mapping#
| Rust | TypeScript |
|---|---|
bool | boolean |
i8 … i32, u8 … u32, f32, f64 | number |
i64, u64 | bigint (or number with #[undra(js_number)] / ts_js_number) |
String, Bytes | string, Uint8Array |
Duration, Timestamp, Uuid | Duration, Timestamp, string |
Option<T>, Vec<T> | T | null, T[] |
Map<K, V> | Map<K, V> |
| record | interface |
| unit enum, data enum | string-literal union, discriminated union on kind |
| error | a class extending UndraError, with subclasses per variant |
64-bit integers become bigint so no value is ever silently rounded; a 64-bit value outside the safe range is a decode error rather than a wrong number when you opt into number.
Records and enums#
/** One item of the to-do list. */
export interface Todo { id: string; title: string; done: boolean }
export type Filter = "all" | "active" | "done";
export type Figure =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "label"; value: string }
| { kind: "empty" };A switch (figure.kind) over the union is exhaustive under strict, so adding a variant in Rust breaks every switch that forgot it.
Errors#
A #[undra::error] becomes an abstract class with a kind and one subclass per variant, so you can branch with instanceof or on kind. A generated call that can fail rejects with exactly one of three things: its own error, as the typed class, not a string to parse; the AbortSignal's reason (an AbortError by default), when the signal you passed cancelled the call; or UndraCallError, for anything else that went wrong with the call. The method's @throws documentation names its error.
export abstract class TodoError extends UndraError {
declare readonly kind: "emptyTitle";
}
export namespace TodoError {
// message: "the title cannot be empty"
export class EmptyTitle extends TodoError {}
}
import { UndraCallError } from "@undra/runtime";
try {
await todos.add(draft, controller.signal);
} catch (error) {
if (error instanceof TodoError) {
setProblem("Give it a title first.");
} else if (error instanceof UndraCallError) {
setProblem(error.message); // panicked, refused, unavailable, …
} else {
throw error; // the AbortError of controller.abort()
}
}UndraCallError extends UndraError, so one error instanceof UndraError shows either kind of failure to the user, and message reads well in an alert. Each case is a class in the UndraCallError namespace, kind is its discriminant, and switch (error.kind) over the UndraCallFailure union is exhaustive. Every failure of the call is a typed value: no reply status, lost connection or undecodable byte reaches you as a raw UndraReplyError or UndraTransportError.
kind | When |
|---|---|
"cancelledByCore" | The core cancelled the call: a restore replaced the object it ran on, or the core shut down. Not the AbortSignal's reason, because your signal did not abort and the write did not land. |
"panicked" (panicMessage, backtrace) | The core panicked while running the call. A native core catches the panic and keeps working; a wasm core traps instead, and the call fails as "unavailable" (reason "trap"). |
"refused" (reason) | The core would not run the call: the object was closed or replaced by a restore, the call came from inside one of the core's callbacks (E_REENTRANT), or its arguments did not decode. |
"unavailable" (transport) | The core cannot be reached: it was closed or never loaded, a wasm core trapped, or the remote connection closed or timed out. transport is an UndraTransportError and its reason says which ("closed", "timeout", "trap", …). While undra dev is reconnecting, every call rejects with it at once ("closed"); so does every call on a core that a rebuild closed for good (onClose receives an UndraSessionLostError). |
"malformed" (detail) | The core answered with something the bindings cannot read. After a successful schema check this is a bug in Undra; report it with the text. |
In a production build, the runtime's own messages are codes. @undra/runtime publishes a development build and a production build of the same code. vite dev, Vitest and React Native resolve the readable one; vite build, webpack in production mode and plain Node resolve the production one, whose message for what the runtime words itself is a code, its values and a link: T0017: callSync, remote; see https://shreypdev.github.io/undra/docs/errors.html#T0017. The errors page has every sentence. The class, name, kind and every typed field are the same in both, and an error from the core (a typed error of yours, a panic message, a refusal reason) travels as its own text in both, so branch on those, never on message. To read sentences in a production build, resolve the development condition (resolve.conditions: ["development"] in Vite).
Commands report instead of rejecting. A method that returns nothing and has no error type, such as counter.increment(), is a command. Its promise never rejects, so an event handler calls it with void and no .catch, and a failure is never an unhandled rejection. If it fails, the runtime logs the failure at error level, passes an UndraUnhandledError to onError and resolves. Awaiting a command tells you it was sent and answered, not that it succeeded: read the effect from the store, which changes only from the core's change-sets. A value the wire cannot represent is reported as "malformed", not thrown. A store that cannot decode a change from the core skips it and reports it the same way, and so does a malformed change-set (dropped whole) or a port implementation that failed.
await UndraPlaygroundCore.load({
mode: "wasm-main",
wasm: new URL(wasmUrl, location.href),
onError: (unhandled) => {
// unhandled.operation is "Todos.toggle", unhandled.error.kind is "refused"
reportToSentry(unhandled);
},
});The handler runs synchronously where the failure was found, so keep it short, and it must not call into Undra. An exception it throws is logged and dropped.
A lost connection is not an onError event. Over undra dev, a command tapped while the dev server is away fails with "unavailable" too, and the connection state (core.connection, or onConnectionChange) already says so, once. A failure that is the connection being down is therefore logged at warning level and not handed to onError; a crash reporter would otherwise get one event per tap for as long as the laptop sleeps. Everything else "unavailable" is still reported: a command on a core you closed yourself, a timeout, and a wasm core that trapped.
Objects and functions#
An object is a class holding a handle. Every method returns a Promise, which keeps the API uniform across main-thread, worker and remote modes. A command's promise resolves even when the call fails (see Errors), and a stream method returns an AsyncIterable that fails with the same errors while you iterate it. An async method takes an optional AbortSignal as its last argument to cancel the call in the core.
export class Probe extends UndraObject {
static create(core?: UndraCore): Promise<Probe>;
counters(): Promise<ProbeCounters>;
wait(ms: number, signal?: AbortSignal): Promise<number>;
ticks(count: number): AsyncIterable<number>; // backpressure through stream credit
reset(): Promise<void>; // a command: never rejects
}
export function add(a: number, b: number): Promise<number>;Release an object with close() (idempotent) or with using where Symbol.dispose exists (Node 20+ and current browsers); a FinalizationRegistry is the backstop.
Objects also cross as parameters and results (Objects and identity), and host callbacks as parameters (Host callbacks): there is one wrapper per object, so === compares the core's objects, and an object of another core is refused with UndraCallError.Refused before anything is sent. The generated classes do this through free functions of @undra/runtime that take the core first, not methods of UndraCore, so a page that uses neither feature does not ship them (the size budget of the up-front chunk): adopt(core, handle, Type), adoptObject, adoptOptional, adoptList and requireOwn(core, object) for objects; lend(core, impl, callback), lending(core, send, signal) and giveBack(core, instance) for callbacks. App code does not call them. callbacks(core) is the registry of the callbacks the core holds (liveCount, count(impl)), which a test reads to check that nothing is left behind.
Stores and signals#
A store extends UndraStore. Every signal is a read-only Signal<T>: get() returns the current value, peek() reads without subscribing, and subscribe(fn) calls you on change and returns an unsubscribe function. Computed signals are written by the core.
export class Todos extends UndraStore {
readonly todos: Signal<Todo[]>;
readonly filter: Signal<Filter>;
readonly visible: Signal<Todo[]>; // computed by the core
readonly remaining: Signal<number>; // computed by the core
static create(core?: UndraCore): Promise<Todos>;
setFilter(f: Filter): Promise<void>; // a command: never rejects
add(title: string, signal?: AbortSignal): Promise<Todo>;
}Change-sets are batched with queueMicrotask into one flush per macrotask, so several signals that changed in one transaction update together and a component never renders a half-applied state.
React and the other frameworks#
@undra/runtime/react exports two hooks. useSignal is useSyncExternalStore over a signal, with a server snapshot. useUndra creates a store when the component mounts and closes it when it unmounts; it returns undefined until the store exists, and useSignal accepts an undefined signal.
import { useUndra, useSignal } from "@undra/runtime/react";
export function CounterView() {
const counter = useUndra(Counter); // undefined until created
const count = useSignal(counter?.count);
const parity = useSignal(counter?.parity); // computed in the core, same change-set
if (counter === undefined) return null;
return <button onClick={() => void counter.increment()}>{count} ({parity})</button>;
}Adapters ship for the other frameworks as thin files, exported from @undra/runtime/vue (useSignal as a shallowRef, useUndra), /svelte (signalStore, a Readable) and /solid (useSignal as an Accessor, useUndra). The frameworks are optional peer dependencies; the core package imports none of them.
Query handles#
A #[undra::query] becomes a store with five signals (data, status, error, fetching, updatedAt) plus refetch() and invalidate().
const inbox = await RemoteTodosQueryHandle.create("inbox");
const items = useSignal(inbox.data); // RemoteTodo[] | null
const busy = useSignal(inbox.fetching);
await inbox.refetch();See Queries & mutations.
Ports#
The ten standard ports and their types are exported from @undra/runtime and are not generated into your package. The browser defaults use fetch, IndexedDB, OPFS, setTimeout, navigator.onLine and visibilitychange; Node uses fetch and files. Replace one with adapters in UndraCore.load. A port you define yourself becomes an interface:
export interface Http extends UndraPort {
request(req: HttpRequest): Promise<HttpResponse>;
}See Ports & adapters.
Testing#
The runtime is testable without a browser: over the remote transport against a native core, and against a WAT stub of the wasm ABI. The contract suite runs the same scenarios as the Swift and Kotlin runners, against the real wasm module.