Frontend runtime — @elyra/runtime
The npm package the Svelte app imports. It speaks the binary wire format so you don't have to.
import { invoke, channel, CommandError } from "@elyra/runtime";
invoke(command, ...args)
Call a Rust #[command] by name. Arguments are MessagePack-encoded; the result
is decoded into the resolved type.
const greeting = await invoke<string>("greet", "world");
const sum = await invoke<number>("add", 2, 3);
If the command returns an error (a Result::Err, or a middleware/decode
failure), the promise rejects with a CommandError carrying the command
name and message.
try {
await invoke("checked_div", 1, 0);
} catch (e) {
if (e instanceof CommandError) console.error(e.message);
}
channel(name)
Subscribe to a server-pushed event channel. The return value is a
Svelte-readable store, so $channel(...) works in a component; it's also
usable standalone.
<script>
import { channel } from "@elyra/runtime";
const cursor = channel("cursor");
</script>
<pre>{JSON.stringify($cursor)}</pre>
const unsub = channel<number>("tick").subscribe((v) => { /* ... */ });
// later: unsub();
All channels are multiplexed over one long-poll connection with automatic reconnect/backoff.
The generated api.* facade
After rata codegen you get bindings.ts with a fully typed
facade — prefer it over stringly-typed invoke:
import { api } from "./bindings";
const todos = await api.list_todos(); // Promise<Todo[]>
const todo = await api.add_todo("milk"); // Promise<Todo>
The facade delegates to invoke under the hood, so error handling is identical.
Live queries — live.*
A #[command(live)] (see commands) gets a
store in the generated bindings next to its api.* call. Rust re-runs the
command whenever the data it read changes — written in this window, another
window, or a background job — and the store updates:
<script>
import { live } from "./bindings";
let query = $state({ search: null, sort: "name", direction: "asc", page: 1, per_page: 25 });
const customers = $derived(live.customers_index(query));
</script>
{#if $customers.loading}
<p>Loading…</p>
{:else if $customers.error && !$customers.value}
<p>{$customers.error.message}</p>
{:else}
{#each $customers.value?.data ?? [] as c (c.id)}<p>{c.name}</p>{/each}
{/if}
The store's value is a Live<T>:
| Field | |
|---|---|
value |
the latest result (undefined until the first one) |
error |
the latest re-run's failure — a CommandError, or a ValidationError — with the previous value kept |
loading |
true until the first result |
The subscription opens with the store's first listener and closes with its
last, so a component's $store is the whole lifecycle. With $derived, a
changed query opens a new subscription and closes the old one. Without
codegen, live<T>(command, ...args) from @elyra/runtime does the same,
untyped.
Origin, CORS, and the IPC token
Everything is same-origin under elyra://localhost (the app is served there,
IPC and events too), so fetch needs no CORS handling in production — and the
shell sends no Access-Control-Allow-* headers there, so a foreign origin
can't read an IPC response. Under rata dev the page loads from Vite's
http://localhost:5173; only then does the shell add CORS, and only for that
exact origin (from ELYRA_DEV_URL).
On top of the origin rule, every /__* request must carry this run's IPC
token. The shell generates a random token per launch and injects it into the
webview before any page script runs (globalThis.__ELYRA__.token);
@elyra/runtime attaches it as x-elyra-token automatically. Requests without
it get 403 and the runtime throws ForbiddenError:
import { invoke, ForbiddenError } from "@elyra/runtime";
try {
await invoke("greet", "World");
} catch (e) {
if (e instanceof ForbiddenError) {
// Refused at the security gate. `e.detail` is the shell's own explanation:
// a bad token, an ungranted capability, or a command's `can = "…"` ability.
console.error(e.detail);
}
}
ForbiddenError covers every refusal at the gate, not only the token — an
ungranted capability and an ungranted
command ability arrive the same way.
e.detail names which one, so read it before assuming the token is at fault.
If you talk to the bridge without @elyra/runtime (a raw fetch), send both
x-elyra-token and x-elyra-client-id yourself.
Routing
A hash-based router (#/customers/12) — it needs nothing from the shell, and
reload / Back / Forward behave as in a browser. rata new scaffolds it: a route
table in src/routes.js, pages in src/pages/, and a Router.svelte that
renders the current one.
// src/routes.js
export const routes = {
"/": () => import("./pages/Home.svelte"),
"/customers": () => import("./pages/Customers.svelte"),
"/customers/new": () => import("./pages/CustomerForm.svelte"),
"/customers/:id": () => import("./pages/Customer.svelte"),
"/*": () => import("./pages/NotFound.svelte"),
};
Each page is loaded on demand (its own chunk) and receives params and query:
<!-- pages/Customer.svelte, for /customers/12?tab=notes -->
<script>
let { params, query } = $props(); // { id: "12" }, { tab: "notes" }
</script>
- Precedence: the most specific pattern wins, whatever the table's order —
static segments beat
:params, and a*wildcard only matches when nothing exact does. So/customers/newbeats/customers/:id. - Links:
<a href={href("/customers/12", { tab: "notes" })}>builds the#…href;$route.path/$route.querygive the current location (e.g. to mark the active nav item). - In code:
navigate("/customers");navigate("/login", { replace: true })for redirects, so Back skips them. - Deep links:
deepLinkPath("myapp://customers/12")→/customers/12, so a URL from theelyra:deep-linkchannel can be handed tonavigate. matchRouteandresolveRouteare exported for other renderers — the router itself has no Svelte dependency.