This project is in alpha — APIs may change without notice.
native-window

Typed IPC

Type-safe bidirectional messaging between host and webview

Overview

The @fcannizzaro/native-window-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 schemas for your events. Types are inferred automatically — no separate type definition needed:

import { z } from "zod";

const schemas = {
  /** Webview -> Host: user clicked somewhere */
  "user-click": z.object({ x: z.number(), y: z.number() }),
  /** Webview -> Host: counter value */
  counter: z.number(),
  /** Host -> Webview: update the title */
  "update-title": z.string(),
  /** Host -> Webview: echo a message */
  echo: z.string(),
};

Schemas serve as the single source of truth for both types and validation.

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 "@fcannizzaro/native-window-ipc";

const schemas = {
  "user-click": z.object({ x: z.number(), y: z.number() }),
  counter: z.number(),
  "update-title": z.string(),
  echo: z.string(),
};

const ch = createWindow(
  { title: "My App", width: 800, height: 600 },
  { schemas },
);

// Send typed messages to the webview
ch.send("update-title", "Hello!");     // payload must be string
ch.send("echo", "Welcome");           // payload must be string

// Receive typed messages from the webview
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", "wrong");         // Type error: string not assignable to number
ch.send("typo", 123);                // Type error: "typo" does not exist
ch.on("counter", (s: string) => {}); // Type error: string not assignable to number

createChannel

If you already have a NativeWindow instance, wrap it with createChannel:

import { z } from "zod";
import { NativeWindow } from "@fcannizzaro/native-window";
import { createChannel } from "@fcannizzaro/native-window-ipc";

const win = new NativeWindow({ title: "App" });
const ch = createChannel(win, {
  schemas: {
    ping: z.string(),
    pong: z.number(),
  },
});

ch.send("ping", "hello");   // typed as string
ch.on("pong", (n) => {});   // n: number

TypedChannel Interface

Both createChannel and createWindow return an object implementing TypedChannel<T>:

interface TypedChannel<T extends EventMap> {
  send<K extends keyof T & string>(...args: SendArgs<T, K>): void;
  on<K extends keyof T & string>(type: K, handler: (payload: T[K]) => void): void;
  off<K extends keyof T & string>(type: K, handler: (payload: T[K]) => void): void;
}

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<T> 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:

<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 "@fcannizzaro/native-window-ipc/client";

const ch = createChannelClient({
  schemas: {
    "update-title": z.string(),
    echo: z.string(),
  },
});

// Fully typed on the webview side too
ch.on("update-title", (title) => {   // title: string
  document.title = title;
});

createChannelClient is idempotent — if window.__channel__ already exists (from the auto-injected script), it returns the existing instance.

ChannelClientOptions

OptionTypeDefaultDescription
schemasSchemaMap(required)Schemas for incoming events — provides types and runtime validation
onValidationError(type, payload) => voidCalled when an incoming payload fails validation

Manual Injection

You can also get the client script as a string for manual injection:

import { getClientScript } from "@fcannizzaro/native-window-ipc";

const script = getClientScript();
win.unsafe.evaluateJs(script);

Channel Options

Pass options as the second argument to createChannel or createWindow:

const ch = createChannel(win, {
  schemas: { ping: z.string(), pong: z.number() },
  injectClient: true,
  onValidationError: (type, payload) => { /* ... */ },
  trustedOrigins: ["https://myapp.com"],
});
OptionTypeDefaultDescription
schemasSchemaMap(required)Schemas for runtime validation + type inference
injectClientbooleantrueAuto-inject the client script into the webview
onValidationError(type, payload) => voidCalled when a payload fails validation
trustedOriginsstring[]Restrict client script injection and incoming messages to these origins

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: {
    "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);
});

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: { ping: z.string() },
  trustedOrigins: ["https://myapp.com", "https://cdn.myapp.com"],
});

Behavior:

  • The initial injection always runs (before any page has loaded)
  • On subsequent page loads (onPageLoad "finished"), 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 trustedOrigins is not set, re-injection and message delivery are unrestricted

Note: trustedOrigins controls 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.

On this page