Backend RPC
Tynd’s RPC is zero-codegen and zero-schema. Every export from your backend file is a callable RPC method on the frontend, typed via typeof backend.
Core pattern
import { app, createEmitter } from "@tynd/core";
// Named exports are RPC methods
export async function greet(name: string): Promise<string> {
return `Hello, ${name}!`;
}
export async function add(a: number, b: number): Promise<number> {
return a + b;
}
// Emitters are frontend event sources
export const events = createEmitter<{
tick: { at: number };
}>();
app.start({
window: { title: "RPC demo", width: 900, height: 600 },
});import { createBackend } from "@tynd/core/client";
import type * as backend from "../backend/main";
const api = createBackend<typeof backend>();
const msg = await api.greet("Alice"); // string, typed
const sum = await api.add(2, 3); // number, typed
const unsub = api.on("tick", ({ at }) => {
console.log("tick at", at);
});
// unsub() when doneUnder the hood createBackend is a thin Proxy that serialises the call as JSON and sends it over window.ipc.postMessage. The return value deserialises as JSON. No codegen, no IDL — types flow from typeof backend.
Supported argument and return types
JSON-serializable:
- Primitives:
string,number,boolean,null,bigint(stringified) - Arrays, plain objects
- Nested structures
Not serializable directly:
Function— no way to roundtrip across IPCMap/Set— convert to array firstDate— serializes as ISO string; pass epoch ms (Date.now()) or parse on arrivalArrayBuffer/Uint8Array— use binary IPC for anything large; for small payloads, base64-encode manually
Error handling
Throwing in the backend rejects the frontend promise:
// backend
export async function divide(a: number, b: number) {
if (b === 0) throw new Error("division by zero");
return a / b;
}
// frontend
try {
await api.divide(10, 0);
} catch (err) {
console.error(err.message); // "division by zero"
}Error objects are serialized with name and message. Stack traces are stripped in production. Use a custom error class + instanceof check if you want structured errors:
export class BusinessError extends Error {
constructor(public code: string, message: string) { super(message); }
}
export async function doThing() {
throw new BusinessError("not_found", "widget not found");
}Frontend receives { name: "BusinessError", message: "widget not found" } — enough to switch on err.name.
Lifecycle hooks
import { app } from "@tynd/core";
app.onReady(() => {
console.log("window shown, WebView alive");
});
app.onClose(() => {
// 2-second watchdog — if handlers don't complete in time, the host force-exits.
console.log("user clicked X, cleaning up");
});
app.start({ window: { title: "…" } });onReadyfires when the WebView emitsDOMContentLoadedon the primary window.onClosefires when the user closes the primary window (WindowEvent::CloseRequested). The window hides immediately; you have ~2 s to finish before a watchdog force-exits.
Emitters — typed events
export const events = createEmitter<{
userCreated: { id: string; name: string };
progress: { percent: number };
}>();
// anywhere in backend code
events.emit("userCreated", { id: "1", name: "Alice" });
events.emit("progress", { percent: 42 });Frontend subscribes:
api.on("userCreated", (user) => console.log(user.name));
api.once("userCreated", (user) => { /* fires once */ });Emitters must be exported. The frontend’s type-only import (typeof backend) needs to see them to type-check api.on("name", …). Forgetting export is a silent type-only error.
Async generators — streaming
An async function* export is a streaming RPC:
export async function* count(to: number) {
for (let i = 1; i <= to; i++) {
yield i;
await Bun.sleep(100);
}
return "done";
}
// frontend
const stream = api.count(5);
for await (const n of stream) console.log(n); // 1, 2, 3, 4, 5
console.log(await stream); // "done"See Streaming RPC for flow control, cancellation, and multi-window routing.
Calling between backend functions
Just regular function calls:
async function loadFromDB(id: string) { /* internal */ }
export async function getUser(id: string) {
return loadFromDB(id);
}Unexported functions are not RPC-accessible — the frontend type import can’t see them.
Modularization
export { greet, farewell } from "./user";
export { list, create } from "./items";
export { app } from "@tynd/core";
import { app } from "@tynd/core";
app.start({ window: { title: "…" } });export async function greet(name: string) { return `Hi, ${name}`; }
export async function farewell(name: string) { return `Bye, ${name}`; }Frontend:
api.greet("Alice");
api.list();The typed proxy flattens all re-exports.
Rules of engagement
One backend file per app
tynd.config.ts::backend points at one entry (default backend/main.ts). All RPC methods and emitters live in the exports of that module (directly or re-exported). This is what types typeof backend — nothing else is reachable.
Never throw sensitive strings
Error messages cross IPC verbatim. Don’t put DB connection strings, secrets, or file paths you wouldn’t show the user in a thrown message.
Keep RPC granularity moderate
10 calls for one user action cost 10 round-trips + 10 promise resolutions. Batch when you control both sides: expose a single loadDashboard() RPC that returns everything the view needs, not 12 small getters.
Return POJOs, not class instances
Class methods don’t survive serialization. If you use classes internally, convert to plain objects at the RPC boundary.
Don’t return cycles
JSON.stringify throws on circular references. Break cycles before returning.
Full-runtime-only patterns
In full mode, the backend has full Bun + Node + npm access:
// full-only — don't do this in lite
import { readFile } from "node:fs/promises";
export async function readConfig() {
return JSON.parse(await readFile("./config.json", "utf8"));
}For cross-runtime code, use the Tynd OS APIs (fs, sql, http, …) — they work identically in both modes:
// works in full AND lite
import { fs } from "@tynd/core/client";
export async function readConfig() {
return JSON.parse(await fs.readText("./config.json"));
}Wait — fs lives in @tynd/core/client. Can the backend use it? Yes: @tynd/core/client is importable from the backend too. The OS APIs dispatch to Rust regardless of which side calls them.