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:
- Authenticates the call against the view's
accesslist - Routes the call to the correct module and method
- Serializes arguments and return values
- 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 onbridge(e.g.,bridge.auth) @query()method → callable on that namespace@command()method → callable on that namespace- Signal keys → valid strings in
signals.subscribe()
// 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.
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.
// 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:
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
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.
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:
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:
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.
// @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 listThis 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.
// 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:
// 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.