Typed IPC
Type-safe bidirectional messaging between host and webview
Overview
The @nativewindow/ipc package adds a typed messaging layer on top of the raw postMessage/onMessage bridge. It gives you:
- Compile-time type checking — event names and payload types are validated by TypeScript
- Structured wire format — messages are encoded as JSON envelopes (
{ $ch: string, p: unknown }) - Auto-injection — the webview-side client script is injected automatically and re-injected on navigation
- Required schema validation — all incoming payloads are validated at runtime against schemas
- Multi-library support — works with Zod, Valibot, and any schema library implementing
safeParse()
The package is pure TypeScript with zero runtime dependencies. It works with both inline HTML and bundled webview apps.
Schema-First Approach
Define directional schemas for your events. Types are inferred automatically — no separate type definition needed:
import { z } from "zod";
const schemas = {
host: {
/** Host -> Webview: update the title */
"update-title": z.string(),
/** Host -> Webview: echo a message */
echo: z.string(),
},
client: {
/** Webview -> Host: user clicked somewhere */
"user-click": z.object({ x: z.number(), y: z.number() }),
/** Webview -> Host: counter value */
counter: z.number(),
},
};Schemas serve as the single source of truth for both types and validation. The host key defines events the host sends to the webview, while client defines events the webview sends to the host.
Host Side
createWindow
The simplest way to get started. Creates a NativeWindow and wraps it with a typed channel in one call:
import { z } from "zod";
import { createWindow } from "@nativewindow/ipc";
const schemas = {
host: {
"update-title": z.string(),
echo: z.string(),
},
client: {
"user-click": z.object({ x: z.number(), y: z.number() }),
counter: z.number(),
},
};
const ch = createWindow({ title: "My App", width: 800, height: 600 }, { schemas });
// Send typed messages to the webview (host events)
ch.send("update-title", "Hello!"); // payload must be string
ch.send("echo", "Welcome"); // payload must be string
// Receive typed messages from the webview (client events)
ch.on("user-click", (pos) => {
// pos: { x: number; y: number }
console.log(`Click at ${pos.x}, ${pos.y}`);
});
ch.on("counter", (n) => {
// n: number
console.log(`Counter: ${n}`);
});
// Access the underlying NativeWindow
ch.window.loadHtml(`<html>...</html>`);
ch.window.onClose(() => process.exit(0));Type errors are caught at compile time:
ch.send("counter", 42); // Type error: "counter" is a client event, not sendable by host
ch.send("typo", 123); // Type error: "typo" does not exist in host schemas
ch.on("update-title", (t) => {}); // Type error: "update-title" is a host event, not receivable by hostcreateChannel
If you already have a NativeWindow instance, wrap it with createChannel:
import { z } from "zod";
import { NativeWindow } from "@nativewindow/webview";
import { createChannel } from "@nativewindow/ipc";
const win = new NativeWindow({ title: "App" });
const ch = createChannel(win, {
schemas: {
host: { ping: z.string() },
client: { pong: z.number() },
},
});
ch.send("ping", "hello"); // typed as string (host event)
ch.on("pong", (n) => {}); // n: number (client event)TypedChannel Interface
Both createChannel and createWindow return an object implementing TypedChannel<Send, Receive>:
interface TypedChannel<Send extends EventMap, Receive extends EventMap> {
send<K extends keyof Send & string>(...args: SendArgs<Send, K>): void;
on<K extends keyof Receive & string>(type: K, handler: (payload: Receive[K]) => void): void;
off<K extends keyof Receive & string>(type: K, handler: (payload: Receive[K]) => void): void;
}The Send type parameter defines events this side can send, while Receive defines events this side can listen for. On the host, Send maps to host schemas and Receive maps to client schemas; on the webview side, the mapping is flipped.
The send() method uses the SendArgs helper type: when the payload type for an event is void or never, the payload argument is optional — you can write ch.send("ping") instead of ch.send("ping", undefined).
The NativeWindowChannel<Send, Receive> returned by createChannel/createWindow extends this with a readonly window property.
Webview Side
Auto-Injected Client
When you use createChannel or createWindow, a client script is automatically injected into the webview. It exposes a window.__channel__ object with the same send/on/off API:
Security: The
window.__channel__object is defined withObject.defineProperty(non-writable, non-configurable). Once set by the first initializer, it cannot be reassigned or deleted by page scripts. Similarly,window.__native_message__is locked after the client script sets it. The injected script capturesArray.prototype.slice,Array.prototype.filter,Array.prototype.push,Array.prototype.indexOf, andArray.prototype.spliceat initialization to guard against prototype pollution. A frozenwindow.__native_message_listeners__registration API is also exposed for third-party message handlers (see Security).
<script>
// __channel__ is available immediately (injected by createChannel)
__channel__.send("user-click", { x: 10, y: 20 });
__channel__.send("counter", 42);
__channel__.on("update-title", function (title) {
document.title = title;
});
__channel__.on("echo", function (msg) {
console.log("Echo:", msg);
});
</script>The client script is re-injected on every page navigation so it survives loadUrl() calls. Disable auto-injection with { injectClient: false }.
Note: The injected client is plain JavaScript — there are no TypeScript types at runtime. Type safety is enforced on the host side only. Always validate payloads at runtime. See Schema Validation.
Bundled Import
For webview apps bundled with their own build step (Vite, webpack, etc.), import the typed client directly instead of relying on the injected script:
import { z } from "zod";
import { createChannelClient } from "@nativewindow/ipc/client";
const ch = createChannelClient({
schemas: {
host: { "update-title": z.string(), echo: z.string() },
client: { "user-click": z.object({ x: z.number(), y: z.number() }), counter: z.number() },
},
});
// Fully typed on the webview side too
ch.on("update-title", (title) => {
// title: string (host event — received by client)
document.title = title;
});createChannelClient connects to the native IPC bridge. If window.__channel__ was already set by the auto-injected script (which locks the property with Object.defineProperty), calling createChannelClient will not overwrite it — the first initializer wins. If auto-injection is disabled, createChannelClient will initialize window.__channel__ and lock it.
createChannelClient also sets up window.__native_message_listeners__, a frozen registration API that allows third-party integrations like the TanStack DB adapter to register message handlers without replacing the frozen window.__native_message__ callback.
ChannelClientOptions
| Option | Type | Default | Description |
|---|---|---|---|
schemas | { host: SchemaMap; client: SchemaMap } | (required) | Directional schemas — host events are validated on receive, client events provide types for send |
onValidationError | (type, payload) => void | — | Called when an incoming payload fails validation |
Manual Injection
You can also get the client script as a string for manual injection:
import { getClientScript } from "@nativewindow/ipc";
const script = getClientScript();
win.evaluateJs(script);When using channelId, pass it to getClientScript so the injected client uses the same namespace:
const script = getClientScript({ channelId: "my-namespace" });
win.evaluateJs(script);Channel Options
Pass options as the second argument to createChannel or createWindow:
const ch = createChannel(win, {
schemas: {
host: { ping: z.string() },
client: { pong: z.number() },
},
injectClient: true,
onValidationError: (type, payload) => {
/* ... */
},
trustedOrigins: ["https://myapp.com"],
channelId: true, // auto-generate a random namespace
rateLimit: 100, // max 100 messages per second
});| Option | Type | Default | Description |
|---|---|---|---|
schemas | { host: SchemaMap; client: SchemaMap } | (required) | Directional schemas — host for events the host sends, client for events the webview sends |
injectClient | boolean | true | Auto-inject the client script into the webview |
onValidationError | (type, payload) => void | — | Called when a payload fails validation |
trustedOrigins | string[] | — | Restrict client script injection and incoming messages to these origins |
maxMessageSize | number | 1_048_576 | Max incoming message size in characters; larger messages are silently dropped |
rateLimit | number | — | Max incoming messages per second (sliding window); excess messages are silently dropped |
maxListenersPerEvent | number | — | Max listeners per event type; calls to on() beyond the limit are silently ignored |
channelId | string | true | — | Channel namespace. All $ch envelope values are prefixed with channelId:. Pass true to auto-generate a random 8-character nonce. Prevents namespace squatting from untrusted scripts |
Schema Validation
TypeScript types are erased at runtime. The webview is an untrusted execution context — it can send any payload shape regardless of your type definitions. Schemas are required when creating channels to ensure all incoming payloads are validated.
Supported Schema Libraries
The IPC package defines a SchemaLike interface that requires only a safeParse() method. Any schema library implementing this interface works out of the box:
- Zod (v4+) — types inferred via
_zod.output - Valibot (v1+) — types inferred via
_types.output - Standard Schema — types inferred via
~standard.types.output
For libraries not matching any of these inference patterns, the inferred type falls back to unknown.
How It Works
When a message arrives, the channel looks up the schema for the event name and calls safeParse() on the payload. If validation fails, the message is dropped and onValidationError is called (if provided). If validation succeeds, the payload is passed to the registered handler.
const ch = createChannel(win, {
schemas: {
host: { echo: z.string() },
client: {
"user-click": z.object({ x: z.number(), y: z.number() }),
counter: z.number().finite(),
message: z.string().max(1024),
},
},
onValidationError: (type, payload) => {
console.warn(`Rejected invalid "${type}" payload:`, payload);
},
});
// Only valid payloads reach your handlers
ch.on("user-click", (pos) => {
// pos is guaranteed to be { x: number; y: number }
console.log(pos.x, pos.y);
});Note: Only incoming messages from the webview are validated against schemas at runtime. Outgoing
send()payloads from the host are not validated — the host is a trusted context. TypeScript enforces type correctness at compile time for outgoing messages.
Trusted Origins
When navigating to external URLs, you may want to restrict where the IPC client is injected. The trustedOrigins option controls this:
const ch = createChannel(win, {
schemas: {
host: { status: z.string() },
client: { ping: z.string() },
},
trustedOrigins: ["https://myapp.com", "https://cdn.myapp.com"],
});Behavior:
- When
trustedOriginsis not set, the client script is injected immediately and re-injected on every page load - When
trustedOriginsis set, the initial injection is deferred until the firstonPageLoad("finished")event from a trusted origin — this prevents the IPC bridge from being available on untrusted initial content (e.g.about:blank) - On subsequent page loads, the client is only re-injected if the page URL's origin matches one of the trusted origins
- Incoming messages from non-matching origins are silently dropped
- If
trustedOriginsis not set, re-injection and message delivery are unrestricted
Note:
trustedOriginscontrols both client script injection and incoming message filtering. However, the raw IPC bridge (window.ipc.postMessage) remains available on the webview side regardless. See the Security guide for the full threat model.