oriel

Commands and events

The typed IPC between the page and Zig: commands the page invokes, events Zig emits, their JSON mapping, threads, errors, limits and the generated TypeScript.

The page and Zig talk through two plain Zig structs. Commands are the pub fn declarations of Commands: the page calls one with invoke("name", args) and gets its result in a Promise. Events are the fields of Events: Zig pushes one with events.emit(.name, payload) and the page receives it with listen("name", callback). Arguments and results cross as JSON, converted by comptime reflection, and the build generates TypeScript from the same structs, so both sides are type-checked.

Use a command for anything the page asks for; use an event for anything Zig has to tell the page on its own (progress, a file changed, a hotkey). For a second window’s page, events can target one window (Windows). For calls into native Kotlin or Swift code, see Extensions.

Availability

PlatformCommands and eventsTransport
LinuxYesWebKitGTK script message handler with reply
WindowsYesWebView2 message channel
macOSYesWKScriptMessageHandlerWithReply
AndroidYesWebMessageListener channel
iOSYesWKScriptMessageHandlerWithReply

Always on: no build flag, no permission. Pages drawn by the native renderer (-Dnative_ui) use the same commands and events through the engine’s own invoke. Only pages the app trusts get the bridge: its own (app://app, or https://app.localhost on Windows and Android, plus the dev server in dev builds) and remote origins a capability names (Security).

Quick start

// src/main.zig
const std = @import("std");
const oriel = @import("oriel");
const app = @import("oriel_app");

pub const Commands = struct {
    pub fn greet(gpa: std.mem.Allocator, args: struct { name: []const u8 }) ![]const u8 {
        if (args.name.len == 0) return error.EmptyName;
        events.emit(.greeted, .{ .name = args.name });
        return std.fmt.allocPrint(gpa, "Hello, {s}!", .{args.name});
    }
};

pub const Events = struct {
    greeted: struct { name: []const u8 },
};
const events = oriel.App.events(Events);

pub fn main(init: std.process.Init) !u8 {
    return oriel.main(init, .{ .commands = Commands, .events = Events }, .{
        .app = app.build,
        .window = .{ .title = "Greeter" },
    });
}
// frontend/src/main.ts (oriel.ts is generated: `oriel types`)
import { invoke, listen } from "./oriel";

const off = listen("greeted", (e) => console.log("greeted", e.name)); // e: { name: string }
try {
  const text = await invoke("greet", { name: "Ada" }); // text: string
  console.log(text);
} catch (err) {
  console.error(String(err)); // "Error: EmptyName"
}
off();

oriel dev and oriel build regenerate frontend/src/oriel.ts before the frontend builds; oriel types does only that.

How it works

sequenceDiagram
  participant P as Page
  participant B as Platform bridge (main thread)
  participant D as ipc.dispatch
  participant W as Worker pool
  P->>B: {"cmd":"greet","args":{...},"token":...}
  B->>B: size, token, isolation, capability
  alt sync command
    B->>D: dispatchRequest(arena)
    D-->>B: JSON result or error text
  else listed in async_commands
    B->>W: dispatchAsync (own arena)
    W->>D: dispatchRequest
    D-->>W: JSON result or error text
    W-->>B: reply queued to the main thread
  end
  B-->>P: Promise resolves or rejects
  Note over B,P: events.emit from any thread: window.oriel.__emit(name, payload)

A call. invoke(cmd, args) sends {cmd, args, token} (plus an id on the channel transports) to the platform bridge (src/platform/<os>/bridge.zig). The bridge refuses a message over ipc.max_request_len (32 MiB) before parsing it, then checks the page’s IPC token, the isolation signature when the isolation pattern is on, and the calling origin’s capabilities. Window commands (oriel:window:*) go to src/core/window_commands.zig, built-ins to ipc.dispatchBuiltin, and the app’s commands to ipc.dispatchRequest (src/core/ipc.zig).

Dispatch. dispatchRequest finds the pub fn whose name is cmd (an inline for over Commands’ declarations, so there is no table to register), fills each parameter (the allocator, the std.Io, the argument value parsed from args with std.json), calls it and serializes the result with std.json.Stringify. A sync command runs right there on the main thread. A command named in async_commands is copied into a job with its own arena and posted to the worker pool (src/core/ThreadPool.zig); its reply is queued back to the main thread.

Events. events.emit(.name, payload) serializes the payload and evaluates window.oriel?.__emit(name, payload) in every window whose page gets the bridge (WebKit), or sends it on the channel (Windows, Android). Called on the main thread (a sync command emitting), the event is delivered at once, so it reaches the page before that command’s reply. From other threads it is queued to the main thread in order. The page’s __emit calls every listener; an exception in one is logged to the console and doesn’t stop the others.

Types. ipc.typescript(Commands, Events) builds the TypeScript module at compile time. The build runs the dev executable (or a small generator) with --emit-types <frontend>/src/oriel.ts, which writes it only when it changed, so the dev server doesn’t reload for nothing.

Examples

A slow command off the UI thread

pub const Commands = struct {
    pub const async_commands = .{"export_notes"};

    /// Runs on a worker: the window stays responsive.
    pub fn export_notes(gpa: std.mem.Allocator, io: std.Io, args: struct { path: []const u8 }) !u64 {
        const notes = try loadNotes(gpa);
        const file = try std.Io.Dir.cwd().createFile(io, args.path, .{});
        defer file.close(io);
        var buf: [4096]u8 = undefined;
        var w = file.writer(io, &buf);
        for (notes) |n| try w.interface.print("{s}\n", .{n.text});
        try w.interface.flush();
        return notes.len;
    }
};

A name in async_commands that isn’t a command is a compile error.

A readable error for the page

pub fn fetch_models(gpa: std.mem.Allocator, io: std.Io, args: struct { base_url: []const u8 }) ![]const []const u8 {
    return listModels(gpa, io, args.base_url) catch |err|
        oriel.ipc.fail("Could not reach {s} ({s})", .{ args.base_url, @errorName(err) });
}
try {
  await invoke("fetch_models", { base_url: url });
} catch (err) {
  status.textContent = (err as Error).message; // "Could not reach http://... (ConnectionRefused)"
}

Progress events from a worker

pub const Events = struct {
    import_progress: struct { done: u32, total: u32 },
    import_finished: void,
};
const events = oriel.App.events(Events);

pub const Commands = struct {
    pub const async_commands = .{"import_all"};

    pub fn import_all(gpa: std.mem.Allocator, args: struct { files: []const []const u8 }) !void {
        for (args.files, 0..) |path, i| {
            try importOne(gpa, path);
            events.emit(.import_progress, .{ .done = @intCast(i + 1), .total = @intCast(args.files.len) });
        }
        events.emit(.import_finished, {});
    }
};
listen("import_progress", ({ done, total }) => (bar.value = done / total));
listen("import_finished", () => toast("Imported")); // payload: null
await invoke("import_all", { files });

Work started outside a command

A tray or hotkey callback runs on the main thread. Long work goes to the pool; touching a window goes back to the main thread:

fn onHotkey() void {
    oriel.App.spawn(transcribe, .{@as(u32, 30)}) catch |err|
        std.log.err("spawn: {s}", .{@errorName(err)});
}

fn transcribe(seconds: u32) !void {
    _ = seconds; // ... record and transcribe ...
    oriel.App.runOnMain(@as(u8, 0), struct {
        fn f(_: u8) void {
            if (oriel.App.getWindow("main")) |w| w.show();
        }
    }.f);
}

Generate the types without compiling the whole app

// build.zig
.frontend = .{ .dir = "frontend", .types_from = .root_decls },

The generator imports src/main.zig and reads pub const oriel_api: oriel.App.Api, or else pub const Commands (and pub const Events if present). Only the command signatures are compiled, so a production build doesn’t compile the whole app a second time for the bindings.

Reference

Commands

Every pub fn of the struct passed as App.Api.commands is a command named after the function. Other declarations (constants, non-pub functions) are ignored, except async_commands.

RuleDetail
ParametersAny order. std.mem.Allocator: the call’s arena. std.Io: the app’s std.Io (the one oriel.main got). At most one other parameter: the arguments, parsed from args. More than one is a compile error (“commands can have at most one arguments type”); a parameter without a concrete type is too.
ReturnAny type std.json.Stringify can write, or an error union of one. void returns null.
pub const async_commands = .{ "a", "b" }These run on the worker pool. Each name must be a pub fn (compile error otherwise).
Name collisionsA name the bridge handles first (open_external, events:ready, oriel:window:*, …) never reaches Commands. Zig identifiers can’t contain :, so only open_external could clash.

Arguments and results as JSON

Arguments are parsed with std.json.parseFromValueLeaky(T, arena, args, .{ .ignore_unknown_fields = true }); results are written with std.json.Stringify.valueAlloc. The TypeScript type is generated from the same Zig type:

ZigJSONTypeScript
booltrue, falseboolean
integers, floatsnumbernumber
[]const u8, [N]u8stringstring
[]const T, [N]Tarray(T)[]
?Tvalue or nullT | null
enum { a, b }"a", "b""a" | "b"
struct { x: T, y: U = default }object{ x: T; y?: U; }
voidnullnull
unions and anything elseas std.json writes itunknown
  • A struct field without a default must be present, or the call fails with MissingField; give optional fields a default (label: ?[]const u8 = null), which also makes them optional (label?:) in TypeScript.
  • Unknown fields in args are ignored.
  • A command without an arguments parameter takes no args (invoke("ping")); args is then null on the wire.
  • JavaScript numbers are doubles: integers beyond 2^53 lose precision. Send big ids as strings.
  • Strings must be UTF-8.

The allocator and memory

The std.mem.Allocator a command receives is an arena created for that call. Everything it allocates is freed after the reply is sent: return memory from it (or static memory) and free nothing. Don’t keep pointers into it past the call; copy what must outlive it into your own allocator. Arguments live in the same arena. Each async call has its own arena, freed on the main thread after the reply.

Threads

WhatThread
A sync commandThe main (UI) thread; the UI waits for it.
A command in async_commandsA worker-pool thread. The pool has one thread per CPU (at least 2, at most 16) and holds at most 1024 waiting tasks; beyond that the call fails with WorkerPoolBusy.
oriel.extensions.call, oriel.systemAi calls, oriel.camera.capturePhoto from the pageAlways a worker (they wait on native code or the user).
events.emit, events.emitToAny thread. On the main thread the event is delivered at once; from others it is queued to the main thread, in order.
App.spawn(func, args)A worker-pool thread.
App.runOnMain(ctx, func)Queued to the main thread from any thread, even from the main thread (it runs after the caller returns).

Windows, web views, the tray and menus may only be touched on the main thread. A window pointer from App.getWindow is freed when the window closes, so use it on the main thread only; from a worker go by label (events.emitTo) or through App.runOnMain. Commands can’t be cancelled.

Errors

A command that returns an error rejects the page’s Promise with an Error whose message is the error’s name (EmptyName), or the text given to oriel.ipc.fail.

DeclarationMeaning
oriel.ipc.fail(comptime fmt, args) error{CommandFailed}Formats a message (at most 2047 bytes; longer is cut and ends in ...) and returns error.CommandFailed; the page sees the message instead of the name. Per thread; used once.
oriel.ipc.errorText(err) [:0]const u8What the page sees for err (used by the bridges).

Errors the IPC itself produces:

ErrorWhen
UnknownCommandNo command, built-in or window command has that name.
MissingField, UnexpectedToken, InvalidCharacter, Overflow, …args doesn’t fit the argument type (std.json errors).
ForbiddenThe page has no IPC token for its origin, the isolation check failed, or the origin’s capability doesn’t grant the command.
RequestTooLargeThe message is longer than max_request_len.
WorkerPoolBusy1024 async tasks are already waiting.
WorkerPoolNotRunningAn async command arrived while the app is shutting down.
IoNotProvidedA command takes std.Io but the app’s pool isn’t running (shutdown).
OutOfMemoryAllocation failed while dispatching.
IsolationStaleThe isolation frame’s key was evicted; the bridge retries once with a new frame.

Events

pub const Events = struct {
    notes_changed: []const Note,
    status: struct { message: []const u8, progress: u32 },
    @"sync-done": void,
};
const events = oriel.App.events(Events);
events.emit(.status, .{ .message = "Saving", .progress = 50 });
try events.emitTo("settings", .status, .{ .message = "Saved", .progress = 100 });
DeclarationMeaning
App.events(comptime Events: type) typeThe app’s events, typed: the name and payload type are checked at compile time. Events is the struct passed as Api.events (a module may pass a struct of its own fields of it).
events.emit(comptime name: std.meta.FieldEnum(Events), payload: @FieldType(Events, name)) voidTo every window.
events.emitTo(label: []const u8, comptime name: std.meta.FieldEnum(Events), payload: @FieldType(Events, name)) !voidTo one window only; error.WindowNotFound when no window has that label.
  • Each field is an event; its type is the payload (void sends null). Names that aren’t JavaScript identifiers ("sync-done") are quoted in the TypeScript.
  • Delivery is best-effort: an event emitted before a page listens is lost, except the events below that wait for the first listen.
  • Events go only to pages that get the bridge (the app’s origins and capability origins), never to another page a window may show.
  • Serialization errors are logged (emit <name>: <error>), not returned.

Events Oriel itself sends (and declares in the generated Events interface where marked):

EventPayloadSent byWaits for listen
deep-link (declared when deep_link is on){ url: string }Deep linksYes (page keeps at most 16)
permission-changed (declared){ name, status }PermissionsNo
lifecycle (declared)LifecycleEventLifecycleYes
notification:actionsee NotificationsNotificationsYes
share:received, share:sentsee ShareShareshare:received
extension:<name>{ name, data }ExtensionsYes
window:created, window:closed (declared){ label: string }WindowsNo

For the events that wait, the bridge’s listen() sends the events:ready built-in, and Zig then delivers what came before (an app launched by a notification click or a link, a lifecycle change before the page loaded).

Page API

APIMeaning
invoke<K>(cmd: K, args?): Promise<Commands[K]["result"]>Call a command (from ./oriel, typed). args is required exactly when the command has an arguments parameter.
listen<K>(event: K, callback: (payload: Events[K]) => void): () => voidSubscribe; returns the unsubscribe function.
window.oriel.invoke(cmd: string, args: unknown): Promise<unknown>The untyped call invoke wraps.
window.oriel.listen(event: string, callback): () => voidThe untyped subscription.
window.oriel.platform{ os: "linux" | "macos" | "windows" | "android" | "ios", arch: string }, fixed per build.
window.oriel.featuresEvery module flag as this build resolved it, camelCase (features.fsWatch).

window.oriel is frozen and defined with Object.defineProperty, so a page script can’t replace it. Its methods call the bridge’s closure, so destructuring works (const { invoke } = window.oriel).

TypeScript generation

frontend/src/oriel.ts (the path is Frontend.types_path, relative to the frontend directory; null turns generation off) exports:

ExportWhat
interface Commandsname: { args: ...; result: ... } for every command
interface EventsEvery event, plus deep-link, permission-changed and lifecycle when the app doesn’t declare them
invoke, listenThe typed functions above
orielWindow, deepLink, lifecycle, extensions, systemAi, camera, permissions, platform, features, orielThe built-in APIs (orielWindow is window.oriel.window; not exported as window, which would shadow the global)
openExternal(url)Open a URL in the default browser (Security)
WindowOptions, WindowHandle, WindowApi, PermissionName, PermissionStatus, PermissionsApi, Platform, Features, DeepLinkApi, LifecycleEvent, LifecycleApi, ExtensionEvent, ExtensionsApi, CapturePhotoOptions, CapturedPhoto, CameraApi, the system AI typesInterfaces for those APIs

It also declares window.oriel globally, so plain scripts get types too.

HowWhat it runs
oriel types / zig build typesThe generator with --emit-types <frontend>/<types_path>
oriel dev, oriel buildThe same, before the frontend builds
Frontend.types_from = .dev_exe (default)The whole development executable is the generator
Frontend.types_from = .root_declsA small generator that imports the root file’s oriel_api or Commands/Events
oriel.writeTypes(io, gpa, api, path)The function behind --emit-types; writes only when the text changed

Types are generated on the host: cross-compiling (-Dtarget=...), or an Android or iOS target, makes zig build types fail with a message; run it natively.

Limits

LimitValueWhere
Request size (page to Zig)32 MiB (ipc.max_request_len)Every bridge, before parsing
oriel.ipc.fail message2047 bytesipc.fail
Worker threadsCPU count, clamped to 2..16ThreadPool.init
Waiting async tasks1024 (ThreadPool.default_max_queued)ThreadPool.submit
Queued deep-link events in the page16 (oldest dropped)bridge_api.js
Files in oriel.share.send from the page16 MiB in allshare:send

Built-in commands

The bridge’s own commands. They pass the same token, isolation and capability checks as the app’s (a remote origin needs them in its capability’s commands), except oriel:window:*, which the window API policy governs.

CommandUsed byResult
open_external { url }openExternalnull; errors DisallowedScheme, InvalidUrl, ControlCharactersNotAllowed
events:ready { event }listen for queued eventsnull; UnknownEvent for another name
permissions:query, permissions:request, permissions:open_settings { name }oriel.permissionsstatus string, or boolean; UnknownPermission
deep_link:currentoriel.deepLink.current()the launch URL or null
share:send, share:read, share:capabilitiesoriel.sharesee Share
camera:captureoriel.camera.capturePhotosee Camera
extensions:calloriel.extensions.callsee Extensions
system_ai:*oriel.systemAi (all but system_ai:cancel run on a worker)see System AI
oriel:window:open, close, show, hide, focus, setTitle, setSize, maximize, fullscreen, startDragging, setThemeColor, get, all, current, emitTooriel.windowsee Windows
oriel:isolatedThe isolation pattern’s signed callsthe inner call’s result
drop:pathoriel.drop.path; native renderer pages only (the path, or null)see Drag and drop

Migrating from 0.11

  • The notification:ready and deep_link:ready commands are gone: the bridge’s listen() sends events:ready ({ event }) for every queued event, deep-link and notification:action included. Pages that only use listen need no change.
  • App.emit(name, payload) (untyped) is gone: declare the event in Events and use App.events(Events).emit(.name, payload).
  • App.emitTo(label, name, payload) and Window.emit(name, payload) are App.events(Events).emitTo(label, .name, payload).

Troubleshooting

SymptomCause and fix
invoke rejects with UnknownCommandThe function isn’t pub, the name differs (commands keep the Zig name, snake_case), or the page is an old build: rebuild, and run oriel types.
MissingFieldA field without a default is missing from args; add = null (or another default) to optional fields.
Forbidden, and the log says blocked command "x" from https://...A remote origin without a capability for the command; add it to the capability’s commands (Security).
refused an IPC call without this page's token in the logA call from a frame, or from a page the bridge doesn’t serve. Only the top frame of an allowed origin can call.
The window freezes during a commandIt is sync: add it to async_commands.
listen never firesThe event was emitted before the page listened (only the events in the table above wait), the page isn’t on an allowed origin, or the name differs (check the generated Events).
Cannot generate TypeScript types when cross-compilingRun oriel types without -Dtarget, on the host.
A big integer arrives changedJavaScript doubles; send it as a string.

Related

  • Concepts: where the bridge sits in an app.
  • Windows: targeted events and the oriel.window API.
  • Security: tokens, capabilities and the isolation pattern that guard every call.
  • Extensions: native code the page reaches through Zig.
  • Development: oriel types and the dev loop.

Search documentation

Type to search the documentation.