Skip to content

Bridge API

The Bridge is the typed IPC channel that connects the Renderer to the Runtime. It is the only way frontend code can interact with the backend — there is no other communication path.


Architecture

Renderer (Browser context)          Runtime (Node.js / Electron main)
──────────────────────────          ──────────────────────────────────
bridge.auth.getMe()          ──▶    AuthService.getMe()   (@query)
bridge.auth.login(...)       ──▶    AuthService.login(...) (@command)
                             ◀──    signals.publish(...)   (signal push)
signals.subscribe(...)

Every call goes through the Bridge Dispatcher — an IPC layer that:

  1. Authenticates the call against the view's access list
  2. Routes the call to the correct module and method
  3. Serializes arguments and return values
  4. Rejects unauthorized calls before they reach any service

How the Bridge is Shaped

The bridge's type signature is derived directly from your runtime services. The mapping is:

  • Module id → namespace on bridge (e.g., bridge.auth)
  • @query() method → callable on that namespace
  • @command() method → callable on that namespace
  • Signal keys → valid strings in signals.subscribe()
ts
// Runtime
@Injectable()
export class AuthService {
    // module id: "auth"
    @query()
    async getMe(): Promise<User | null> {}

    @command()
    async login(email: string, password: string): Promise<void> {}
}

// Renderer — automatically shaped as:
bridge.auth.getMe(); // () => Promise<User | null>
bridge.auth.login(email, password); // (string, string) => Promise<void>

This mapping is generated by electro generate. See Code Generation.


Queries

Queries are read-only calls. They map to @query() methods in the runtime.

ts
import { bridge } from "@electrojs/renderer";

// Single item
const user = await bridge.auth.getMe();

// List
const workspaces = await bridge.workspace.getWorkspaces();

// Parameterized
const project = await bridge.project.getById("proj_abc123");

Queries should never cause side effects. If you need both data and a side effect, use a command.


Commands

Commands may change state. They map to @command() methods in the runtime.

ts
// No return value
await bridge.auth.logout();

// Returns updated entity
const updated = await bridge.user.updateProfile({ name: "Alice" });

// Returns newly created entity
const project = await bridge.project.create({ name: "My Project" });

Commands can throw. Always wrap them in try/catch when the operation can fail:

ts
try {
    await bridge.auth.login(email, password);
} catch (err) {
    setError("Login failed. Check your credentials.");
}

Signals

Signals are one-way push notifications from the Runtime to the Renderer. They are asynchronous and fire-and-forget — the runtime does not wait for the renderer to process them.

Subscribing

ts
import { signals } from "@electrojs/renderer";

const sub = signals.subscribe("workspace:created", (workspace) => {
    setWorkspaces((prev) => [...prev, workspace]);
});

Unsubscribing (mandatory)

Every subscription must be cancelled when it is no longer needed. Failure to unsubscribe is a memory leak.

tsx
useEffect(() => {
    const sub = signals.subscribe("project:updated", (project) => {
        setProjects((prev) => prev.map((p) => (p.id === project.id ? project : p)));
    });

    return () => sub.unsubscribe(); // ← called on component unmount
}, []);

One-time Subscription

For signals you only need to receive once:

ts
signals.once("auth:user-logged-in", ({ user }) => {
    initializeUserPreferences(user.id);
});

Managing Multiple Subscriptions

When subscribing to several signals in one effect, collect them and clean up together:

tsx
useEffect(() => {
    const subs = [
        signals.subscribe("project:created", onCreated),
        signals.subscribe("project:updated", onUpdated),
        signals.subscribe("project:deleted", onDeleted),
    ];

    return () => subs.forEach((s) => s.unsubscribe());
}, []);

Access Control

The bridge enforces the access list declared in @View. If a renderer tries to call a method not in its access list, the call is rejected before reaching any service.

ts
// @View({ id: "settings", access: ["settings:get", "settings:update"] })

// In the renderer for this view:
await bridge.settings.get();       // ✅ allowed
await bridge.settings.update({});  // ✅ allowed
await bridge.auth.login(...);      // ❌ rejected: not in access list

This applies to signals too — a renderer only receives signals that are in its signals list.


Error Handling

Errors thrown by runtime service methods are serialized and re-thrown as Error instances in the Renderer.

ts
// Runtime
@command()
async login(email: string, password: string) {
    const result = await this.http.post("/api/auth/login", { email, password });
    if (!result.ok) throw new Error("Invalid credentials");
    this.state.setSession(result.session);
}

// Renderer
try {
    await bridge.auth.login(email, password);
} catch (err) {
    if (err instanceof Error) {
        showNotification(err.message);
    }
}

Type Reference

After running electro generate, ElectroJS writes package-local electro-env.d.ts files that augment @electrojs/renderer. The generated interface looks like:

ts
// views/main/electro-env.d.ts (auto-generated — do not edit)
declare module "@electrojs/renderer" {
    export interface BridgeCommands {
        "auth:login": { input: { email: string; password: string }; output: void };
        "auth:logout": { input: void; output: void };
        "project:create": { input: { name: string }; output: Project };
    }

    export interface BridgeQueries {
        "auth:getMe": { input: void; output: User | null };
        "project:getProjects": { input: void; output: Project[] };
    }

    export interface BridgeSignals {
        "auth:user-logged-in": { user: User; isNew: boolean };
        "auth:user-logged-out": void;
        "project:created": Project;
    }
}

You never import or reference this file directly. Include electro-env.d.ts in the package tsconfig.json and bridge/signals will be typed automatically.