Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Starf An activity monitor for Apple silicon that never invents a number etrans An SSH and SFTP client for macOS Elyra VM Virtual machines for macOS, Linux and Windows on your Mac Litr A small, native web browser for macOS Notr A notebook for macOS e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Refr Local-first PDF workspace for macOS Elyra Workspace A desktop workspace for coding agents Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Release notes
Changelog
Elyra

Commands

A command is the compiled equivalent of a Laravel controller action. Annotate an async fn with #[command] and register it with commands![...].

use elyra::{command, commands, App, Ctx};

#[command]
async fn greet(ctx: Ctx, name: String) -> String {
    format!("Hello, {name}!")
}

App::new().commands(commands![greet, add]).run();

How it works

#[command] turns the function into a zero-sized type of the same name that implements the Command trait — so the function name doubles as the value you pass to commands![...]. Arguments are decoded from a compact MessagePack array; the return value is encoded as a named map (see wire format).

The Ctx

The first parameter is always the context and is passed through untouched. Use it to resolve services from the container:

#[command]
async fn greet(ctx: Ctx, name: String) -> String {
    let cfg = ctx.get::<Config>();     // Arc<Config>, panics if unbound
    format!("{} {name}", cfg.greeting)
}

Name it _ctx if unused.

Arguments and return types

  • Arguments must be simple identifiers with types that implement serde::Deserialize and specta::Type (for codegen).
  • The return type must implement serde::Serialize + specta::Type.
  • Structs are serialized as named maps → plain JS objects, resilient to field reordering across versions.
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
struct Point { x: i64, y: i64 }

#[command]
async fn shift(_ctx: Ctx, p: Point) -> Point { Point { x: p.x + 1, y: p.y + 1 } }

Zero-argument commands ignore the request body entirely.

Fallible commands (Result)

Return Result<T, E> where E: Display. Ok(v) is serialized as T; Err(e) becomes an error response — the frontend promise rejects with a CommandError. Codegen surfaces T:

#[command]
async fn checked_div(_ctx: Ctx, a: i64, b: i64) -> Result<i64, String> {
    if b == 0 { Err("cannot divide by zero".into()) } else { Ok(a / b) }
}
try { await api.checked_div(1, 0); } catch (e) { /* CommandError */ }

elyra::Result<T> is the convenient shape when a command mixes kinds of failure: ? converts a ValidationErrors bag (which reaches the frontend as a ValidationError, fields intact) and — with the database feature — a query error, and elyra::Error::command(msg) covers the rest:

#[command]
async fn rename(ctx: Ctx, id: i64, name: String) -> elyra::Result<User> {
    let db = ctx.get::<Database>();
    Validator::new(&serde_json::json!({ "name": name }))
        .rule("name", "required|max:80")
        .validate()?;
    let mut user = User::find(&db, id).await?.ok_or_else(|| elyra::Error::command("no such user"))?;
    user.name = name;
    user.save(&db).await?;
    Ok(user)
}

Calling from the frontend

import { invoke } from "@elyra/runtime";
const greeting = await invoke<string>("greet", "world");

// or the typed facade after `rata codegen`:
import { api } from "./bindings";
const greeting = await api.greet("world");

Live commands (live)

#[command(live)] lets the frontend subscribe to a command's result: Elyra records the tables it reads, re-runs it when one of them is written, and pushes the new result to the windows watching — see live queries. A live command must only read; a write inside one fails. In tests:

let mut count = app.live::<i64>("posts_count", ()).await;
app.invoke_ok::<i64>("posts_store", ("hello",)).await;
assert_eq!(*count.next().await, 1);

On the frontend, codegen gives each live command a store — see live queries.

Gating a command (can = "…")

Capability::Commands opens all of /__cmd/* in one grant, so a script that gets into the page reaches every command you register. Mark the ones that shouldn't be in that blanket:

#[command(can = "posts.delete")]
async fn delete_post(ctx: Ctx, id: i64) -> elyra::Result<()> {
    // … unchanged
}

The command is now denied to the frontend until the app grants the ability:

App::new()
    .commands(commands![delete_post])
    .allow_ability("posts.delete")   // or "posts.*"

Without the grant the call answers 403 and names what's missing. Rust-side callers — providers, queue jobs, TestApp — are unaffected. See security.

Limitations (deliberate)

  • The macro assumes the first parameter is the Ctx.
  • No generics, no Option<Ctx>, no self receivers.
  • Numeric codegen: 64-bit integers render as number — see codegen.

Cancellation

A slow or long-running command can be cancelled from the frontend with invokeCancellable — the Rust task is aborted at its next .await. Cancelling rejects the result promise with a CommandError.

import { invokeCancellable } from "@elyra/runtime";

const job = invokeCancellable<Report>("build_report", opts);
onDestroy(() => job.cancel());     // stop it when the component unmounts
const report = await job.result;

The generated api.* uses the plain (non-cancellable) invoke; reach for invokeCancellable when you specifically need to abort. Because abortion happens at await points, make cancellable commands .await periodically (I/O, chunks) for prompt cancellation.

Progress

There's no special progress channel — emit on the event bus, which is exactly what it's for:

#[command]
async fn build_report(ctx: Ctx) -> Report {
    let bus = ctx.get::<EventBus>();
    for (i, step) in steps.iter().enumerate() {
        let pct = ((i as f64 / steps.len() as f64) * 100.0) as u8;
        let _ = bus.emit("report:progress", &pct);
        // … do the step …
    }
    report
}
import { channel, invokeCancellable } from "@elyra/runtime";

let pct = 0;
const off = channel<number>("report:progress").subscribe((p) => { if (p != null) pct = p; });
const job = invokeCancellable<Report>("build_report");
onDestroy(() => { job.cancel(); off(); });
const report = await job.result;

Errors on the wire

A failed command responds with x-elyra-status: error and an x-elyra-error-kind telling the frontend what kind of failure it was:

Kind Meaning Frontend type
command the command's own Err (message verbatim) CommandError
validation a ValidationErrors bag ValidationError (parsed errors)
panic the handler panicked CommandError (kind panic)
cancelled aborted via invokeCancellable().cancel() CommandError
forbidden missing IPC token or capability ForbiddenError
bad-request body too large / nested too deep CommandError
your own Err(Error::with_kind("offline", "No connection")) CommandError with that kind

A command picks a kind with elyra::Error::with_kind(kind, message), and the frontend reads it as CommandError.kind. The Laravel backend uses these:

Kind When
offline no answer from the server (connection, DNS, TLS, timeout)
unauthenticated the server says the token is gone (401, 419)
denied the server's policy said no (403)
not-found 404
too-many-requests 429
server 5xx

They reach the frontend from a command and from a live query's re-run alike.

A panicking command answers with an error instead of hanging the caller: every command runs on its own task, so a panic (including a missing container binding, which panics by design) becomes a 500 with the panic message, and is logged at error level.

Input limits

Request bodies are capped at 16 MiB (App::max_request_body) and MessagePack nested deeper than 64 levels is rejected before it reaches serde — a ~10 KB body of nested arrays would otherwise overflow the stack. See security.

Related