Contents

Inter-Process Communication

How to call typed services and subscribe to streamed events between the renderer process and the main process.

The renderer process runs in a sandbox that prevents direct access to Node.js APIs and operating system resources. Inter-Process Communication (IPC) is the mechanism that bridges this gap. When you need to perform a privileged operation in the renderer process you can use IPC to send a message to the main process and receive a response.

Overview 

The framework uses Protocol Buffers (Protobuf) to define a type-safe interface between the renderer process and the main process. The generated project is configured with all the required dependencies and includes the code demonstrating how to send and handle IPC messages between the renderer process and the main process.

The workflow consists of the following steps:

  1. Define messages and services in the src/renderer/proto/ directory.
  2. Generate stubs for both the renderer process and the main process.
  3. Implement the service methods in the main process.
  4. Call the service methods from the renderer process.

Let’s go through the workflow step by step in the sections below.

Defining messages and services 

Messages and services are the core of your IPC contract:

  • Messages define the data shape passed between processes.
  • Services define the RPC methods the renderer process can call.

Here is an example of the src/renderer/proto/greet.proto file you can find in the generated project:

syntax = "proto3";

import "google/protobuf/wrappers.proto";

// Message definition.
message Person {
  string name = 1;
}

// Service definition.
service GreetService {
  rpc SayHello(Person) returns (google.protobuf.StringValue);
}

Generating code 

Once you have defined the messages and services, you can generate the code for both the renderer process and the main process using the following command:

npm run gen

This command reads your Protobuf definitions and generates TypeScript bindings:

  • The code for the main process will be placed in the src/main/gen directory.
  • The code for the renderer process will be placed in the src/renderer/gen directory.
.vscode/
assets/
resources/
src/
├── main/
    ├── gen/
    ├── index.ts
├── renderer/
    ├── gen/
    ├── proto/
        ├── greet.proto
mobrowser.conf.json
package.json
tsconfig.json
tsconfig.node.json
vite.config.ts

Implementing services in the main process 

In the main process, implement the generated service methods. This is where you can implement the privileged logic, such as filesystem access, native APIs, process management, etc.

Here is an example of the service implementation in the src/main/index.ts file:

import { ipc } from '@mobrowser/api';
import { Person } from './gen/greet';
import { GreetServiceDescriptor } from './gen/ipc_service';

// Handle the IPC calls from the renderer process.
ipc.registerService(GreetServiceDescriptor, {
  async SayHello(person: Person) {
    return { value: `Hello, ${person.name}!` };
  },
});

Calling services from the renderer process 

In the renderer process, import the generated client and call service methods as regular async functions. Here is an example demonstrating how to call the generated service methods from the renderer process:

import { ipc } from "./gen/ipc";

ipc.greet.SayHello({ name: 'John' }).then((message) =>
  console.log(message.value) // Hello, John!
)

Server streaming 

For events the main process emits over time — download progress, theme changes, notifications, log tailing, AI completions — declare a server-streaming method using Protobuf’s stream keyword on the response type. The renderer subscribes once and receives messages as the main process produces them:

// src/renderer/proto/files.proto
syntax = "proto3";

import "google/protobuf/empty.proto";

message FileChanged {
  string path = 1;
}

service FilesService {
  rpc OnFileChanged(google.protobuf.Empty) returns (stream FileChanged);
}

The generated renderer client returns a Stream<T> instead of a Promise<T>. Stream<T> is cold — each consumption opens its own subscription — and integrates flawlessly with every major UI framework: it satisfies the TC39 Observable contract, Svelte’s store contract, and [Symbol.asyncIterator] simultaneously, so React, Vue, Svelte, Angular, and RxJS accept it as-is.

There are two ways to implement a streaming method on the main side, depending on whether the data has a single source of truth or each subscription owns its own resource.

Pub/sub fan-out 

When every subscriber should see the same stream — OS theme changes, application-wide notifications, broadcast events — call ipc.registerService(descriptor) with no implementation. The runtime owns the handler and the subscriber set; you publish from wherever the state changes:

// src/main/files.ts
import { watch } from 'node:fs';
import { ipc } from '@mobrowser/api';
import { FilesServiceDescriptor } from './gen/ipc_service';

const files = ipc.registerService(FilesServiceDescriptor);

watch('./data', (event, file) => {
  if (event === 'rename' && file) files.OnFileChanged({ path: file });
});

When no renderer is subscribed, the publish call is a cheap no-op (no encoding). Otherwise the message is encoded once and dispatched to every current subscriber.

Async generator handler 

When the request shapes the stream, or each subscription owns its own resource (an AI session, a streaming HTTP request, a paginated DB query), implement the handler as an async * generator. It runs once per subscribing renderer, sees the request, and lives until the renderer disconnects.

For example, declare a chat completion that streams tokens shaped by the user prompt:

// src/renderer/proto/chat.proto
syntax = "proto3";

message ChatRequest { string prompt = 1; }
message ChatChunk   { string text = 1; }

service ChatService {
  rpc StreamChat(ChatRequest) returns (stream ChatChunk);
}

And implement the handler on the main side:

// src/main/chat.ts
import OpenAI from 'openai';
import { ipc } from '@mobrowser/api';
import { ChatServiceDescriptor } from './gen/ipc_service';

const openai = new OpenAI();

ipc.registerService(ChatServiceDescriptor, {
  async *StreamChat(req, ctx) {
    const completion = await openai.chat.completions.create(
      { model: 'gpt-4o-mini',
        messages: [{ role: 'user', content: req.prompt }],
        stream: true },
      { signal: ctx.signal },
    );
    for await (const chunk of completion) {
      const text = chunk.choices[0]?.delta?.content;
      if (text) yield { text };
    }
  },
});

ctx.signal is an AbortSignal that fires when the renderer disconnects, so it plumbs straight into any abortable upstream — fetch, the OpenAI SDK, node:events’s on(emitter, name, { signal }), and so on. For resources without a signal-aware API, wrap the body in try/finally and close them in the finally block — either way the handler tears down deterministically when the renderer goes away.

Consuming streams in the renderer 

Stream<T> can be consumed two ways. Subscribe form is the natural fit for UI components whose lifetime maps to a framework hook:

// React
React.useEffect(() => {
  const sub = ipc.files.OnFileChanged({}).subscribe({
    next: setFile,
    error: console.error,
  });
  return () => sub.unsubscribe();
}, []);

The same Stream<T> value also plugs into Vue (useObservable), Svelte ($store syntax), Angular (async pipe), and RxJS (from()) without any wrapper.

Pull form is the natural fit for sequential, request-driven logic. break tears the subscription down through the same path as unsubscribe() — and the abort travels all the way back to the upstream call:

for await (const chunk of ipc.chat.StreamChat({ prompt })) {
  appendText(chunk.text);
  if (userClickedStop()) break; // cancels the upstream OpenAI request
}

unsubscribe(), break from for await, an aborted AbortSignal, and frame unload all collapse to one cancellation chain — the underlying transport closes deterministically without any per-call cleanup boilerplate.

Error handling 

Both unary and streaming RPCs surface failures as a single structured IpcError, exported from @mobrowser/api:

class IpcError extends Error {
  readonly name: 'IpcError';
  readonly code: string;
  readonly details?: unknown; // round-trips as JSON
}

Throw one from any handler with a typed code and arbitrary JSON-serializable details:

throw new IpcError({
  code: 'PERMISSION_DENIED',
  message: 'Downloads are disabled by policy',
  details: { requestedUrl: req.url, reason: 'managed-policy' },
});

The renderer catches a real IpcError instance — e instanceof IpcError, e.code, and e.details all work without per-call boilerplate:

import { IpcError } from '@mobrowser/api/streams';

try {
  await ipc.downloads.StartDownload({ url });
} catch (e) {
  if (e instanceof IpcError && e.code === 'PERMISSION_DENIED') {
    showUpsell(e.details);
    return;
  }
  throw e;
}

Any error value with a .code field works — Node’s fs errors, pg driver errors, and friends — the runtime duck-types code, message, and details on the way out. For streams, a thrown handler surfaces as a rejected iter.next() (for for await) or obs.error(e) (for subscribe); the stream then ends with no auto-retry. Renderer disconnects are not errors: subscribers cleanly resolve with { done: true }.