Chapter 3 of 15

Commands: Rust that the page can call

A command is the compiled equivalent of a Laravel controller action. Freddy writes one, registers it, generates its TypeScript, and calls it from a Svelte page as if it were a local function.

A command

Annotate an async fn with #[command] and register it with commands![...]. This is the guide's own first example:

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();

commands![...] is Freddy's routes/web.php. The macro turns the function into a type of the same name, which is why the function name is also what you pass to the list.

The first parameter is always the context, Ctx, passed through untouched. It is how a command reaches the container, which chapter 4 is about; name it _ctx when a command does not need it. Arguments must implement serde::Deserialize and specta::Type, and the return type serde::Serialize and specta::Type. Structs go over as named maps, so they arrive as plain JavaScript objects and survive fields being reordered between versions.

Calling it from Svelte

The low-level call is invoke from @elyra/runtime. The one Freddy will actually use is the typed facade that rata codegen writes:

import { api } from "./bindings";
const greeting = await api.greet("world");

rata codegen writes app/src/bindings.ts from the app's commands: a TypeScript type for every Rust type they use, and an api object whose methods call invoke. It is the framework's Facade, generated, so a renamed field in Rust is a type error in Svelte instead of a surprise at runtime. Run it after changing a command's signature or a type it uses.

One detail from the codegen page that matters for Freddy's note ids: 64-bit integers render as number, because on the MessagePack wire they already arrive as JavaScript numbers.

When it fails

A command that can fail returns Result<T, E> where E: Display. Ok becomes the value; Err makes the frontend's promise reject with a CommandError. When a command mixes kinds of failure, elyra::Result<T> is the convenient shape: ? converts a validation error bag, which reaches the frontend as a ValidationError with its fields intact, and a query error, and elyra::Error::command(msg) covers the rest.

The frontend can tell failures apart, because every error carries a kind:

KindMeaningFrontend type
commandthe command's own ErrCommandError
validationa validation error bagValidationError
panicthe handler panickedCommandError
cancelledcancelled from the frontendCommandError
forbiddenmissing token or capabilityForbiddenError
your ownError::with_kind("archived", "…")CommandError with that kind

A panic does not hang the caller: every command runs on its own task, so a panic becomes an error response with the panic message, and is logged. Chapter 11 adds the kinds a Laravel backend's answers become, such as offline and unauthenticated.

Long commands: cancel and progress

Freddy's export of all his notes to Markdown takes a while. The page can cancel it with invokeCancellable; the Rust task is aborted at its next .await, so a cancellable command should await now and then:

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

const job = invokeCancellable("build_report", opts);
onDestroy(() => job.cancel());
const report = await job.result;

There is no special progress channel. The command emits on the event bus, and the page listens with channel(), which chapter 4 introduces.

Keeping the page away from a command

Remember the untrusted page. One grant opens all commands to it, so a command that should not be in that blanket is marked with an ability:

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

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

Without the grant, a call from the page answers 403 and names what is missing. Calls from Rust, from providers, queue jobs and tests, are not affected. Freddy marks his "delete every note" command this way and grants it only once he is sure.

The whole story is on the commands page, with the wire format on its own page and the frontend side on the frontend runtime page.

What you learned

  • A command is #[command] async fn with Ctx first, registered with commands![...].
  • rata codegen writes bindings.ts: types and a typed api.*.
  • elyra::Result and error kinds let the page tell a validation error from a panic, a cancellation or a refusal.
  • invokeCancellable stops a long command at its next await.
  • #[command(can = "...")] keeps a command away from the page until the app grants the ability.
Next: in Chapter 4 Freddy meets the container, providers, middleware and the event bus.