Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 58 additions & 39 deletions docs/content/docs/ipc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,28 @@ The package is pure TypeScript with zero runtime dependencies. It works with bot

## Schema-First Approach

Define schemas for your events. Types are inferred automatically — no separate type definition needed:
Define directional schemas for your events. Types are inferred automatically — no separate type definition needed:

```ts
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(),
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.
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

Expand All @@ -47,19 +51,23 @@ import { z } from "zod";
import { createWindow } from "@nativewindow/ipc";

const schemas = {
"user-click": z.object({ x: z.number(), y: z.number() }),
counter: z.number(),
"update-title": z.string(),
echo: z.string(),
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
// 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
// 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}`);
Expand All @@ -78,9 +86,9 @@ ch.window.onClose(() => process.exit(0));
Type errors are caught at compile time:

```ts
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
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 host
```

### `createChannel`
Expand All @@ -95,30 +103,32 @@ import { createChannel } from "@nativewindow/ipc";
const win = new NativeWindow({ title: "App" });
const ch = createChannel(win, {
schemas: {
ping: z.string(),
pong: z.number(),
host: { ping: z.string() },
client: { pong: z.number() },
},
});

ch.send("ping", "hello"); // typed as string
ch.on("pong", (n) => {}); // n: 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<T>`:
Both `createChannel` and `createWindow` return an object implementing `TypedChannel<Send, Receive>`:

```ts
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;
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<T>` returned by `createChannel`/`createWindow` extends this with a `readonly window` property.
The `NativeWindowChannel<Send, Receive>` returned by `createChannel`/`createWindow` extends this with a `readonly window` property.

## Webview Side

Expand Down Expand Up @@ -158,14 +168,14 @@ import { createChannelClient } from "@nativewindow/ipc/client";

const ch = createChannelClient({
schemas: {
"update-title": z.string(),
echo: z.string(),
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
// title: string (host event — received by client)
document.title = title;
});
```
Expand All @@ -178,7 +188,7 @@ ch.on("update-title", (title) => {

| Option | Type | Default | Description |
| ------------------- | ------------------------- | -------------- | ------------------------------------------------------------------- |
| `schemas` | `SchemaMap` | **(required)** | Schemas for incoming events — provides types and runtime validation |
| `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
Expand All @@ -205,7 +215,10 @@ Pass options as the second argument to `createChannel` or `createWindow`:

```ts
const ch = createChannel(win, {
schemas: { ping: z.string(), pong: z.number() },
schemas: {
host: { ping: z.string() },
client: { pong: z.number() },
},
injectClient: true,
onValidationError: (type, payload) => {
/* ... */
Expand All @@ -218,7 +231,7 @@ const ch = createChannel(win, {

| Option | Type | Default | Description |
| ---------------------- | ------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `schemas` | `SchemaMap` | **(required)** | Schemas for runtime validation + type inference |
| `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 |
Expand Down Expand Up @@ -248,9 +261,12 @@ When a message arrives, the channel looks up the schema for the event name and c
```ts
const ch = createChannel(win, {
schemas: {
"user-click": z.object({ x: z.number(), y: z.number() }),
counter: z.number().finite(),
message: z.string().max(1024),
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);
Expand All @@ -272,7 +288,10 @@ When navigating to external URLs, you may want to restrict where the IPC client

```ts
const ch = createChannel(win, {
schemas: { ping: z.string() },
schemas: {
host: { status: z.string() },
client: { ping: z.string() },
},
trustedOrigins: ["https://myapp.com", "https://cdn.myapp.com"],
});
```
Expand Down
59 changes: 35 additions & 24 deletions docs/content/docs/react.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@ import { z } from "zod";
import { createChannelHooks } from "@nativewindow/react";

export const { ChannelProvider, useChannel, useChannelEvent, useSend } = createChannelHooks({
/** Host -> Webview: update the displayed title */
"update-title": z.string(),
/** Webview -> Host: user clicked a button */
counter: z.number(),
host: {
/** Host -> Webview: update the displayed title */
"update-title": z.string(),
},
client: {
/** Webview -> Host: user clicked a button */
counter: z.number(),
},
});
```

Expand Down Expand Up @@ -76,7 +80,7 @@ function Counter() {
const send = useSend();

useChannelEvent("update-title", (title) => {
// title is inferred as string
// title is inferred as string (host event)
document.title = title;
});

Expand Down Expand Up @@ -162,19 +166,23 @@ A complete React webview app using `createChannelHooks`:
import { z } from "zod";

export const schemas = {
"update-title": z.string(),
"show-notification": z.object({
message: z.string(),
level: z.enum(["info", "warn", "error"]),
}),
counter: z.number(),
"user-action": z.string(),
host: {
"update-title": z.string(),
"show-notification": z.object({
message: z.string(),
level: z.enum(["info", "warn", "error"]),
}),
},
client: {
counter: z.number(),
"user-action": z.string(),
},
};
```

```ts
// channel.ts
import { createChannelHooks } from "@nativewindow/ipc";
import { createChannelHooks } from "@nativewindow/react";
import { schemas } from "./schemas";

export const { ChannelProvider, useChannelEvent, useSend } = createChannelHooks(schemas);
Expand Down Expand Up @@ -232,9 +240,11 @@ const ch = createWindow({ title: "My App", width: 800, height: 600 }, { schemas

ch.window.loadUrl("http://localhost:5173"); // Vite dev server

// Host receives client events
ch.on("counter", (n) => console.log("Counter:", n));
ch.on("user-action", (action) => console.log("Action:", action));

// Host sends host events
ch.send("update-title", "Hello from host!");
ch.send("show-notification", {
message: "Connected to host",
Expand All @@ -251,15 +261,16 @@ import { z } from "zod";
import { ChannelProvider, useChannel, useChannelEvent, useSend } from "@nativewindow/react";

const schemas = {
counter: z.number(),
title: z.string(),
host: { title: z.string() },
client: { counter: z.number() },
};

type Events = { counter: number; title: string };
type SendEvents = { counter: number };
type ReceiveEvents = { title: string };

function App() {
const send = useSend<Events>();
useChannelEvent<Events, "title">("title", (t) => {
const send = useSend<SendEvents>();
useChannelEvent<ReceiveEvents, "title">("title", (t) => {
document.title = t;
});
return <button onClick={() => send("counter", 1)}>+1</button>;
Expand All @@ -278,7 +289,7 @@ The standalone `ChannelProvider` accepts `schemas` and `onValidationError` as pr

| Prop | Type | Default | Description |
| ------------------- | ------------------------- | -------------- | ------------------------------------------------- |
| `schemas` | `SchemaMap` | **(required)** | Schemas for runtime validation and type inference |
| `schemas` | `{ host: SchemaMap; client: SchemaMap }` | **(required)** | Directional schemas for runtime validation and type inference |
| `onValidationError` | `(type, payload) => void` | — | Called when an incoming payload fails validation |
| `children` | `ReactNode` | **(required)** | React children |

Expand All @@ -290,11 +301,11 @@ The standalone `ChannelProvider` accepts `schemas` and `onValidationError` as pr
| --------------------------------------- | --------- | ----------------------------------------------------------------------------- |
| `createChannelHooks(schemas, options?)` | Factory | Returns pre-typed `{ ChannelProvider, useChannel, useChannelEvent, useSend }` |
| `ChannelProvider` | Component | Standalone context provider (requires `schemas` prop) |
| `ChannelProviderProps<S>` | Type | Props interface for the standalone provider |
| `useChannel<T>()` | Hook | Standalone: access the typed channel from context |
| `useChannelEvent<T, K>(type, handler)` | Hook | Standalone: subscribe to events with automatic cleanup |
| `useSend<T>()` | Hook | Standalone: stable `send` function |
| `TypedChannelHooks<T>` | Type | Return type of `createChannelHooks` |
| `ChannelProviderProps<H, C>` | Type | Props interface for the standalone provider |
| `useChannel<Send, Receive>()` | Hook | Standalone: access the typed channel from context |
| `useChannelEvent<Receive, K>(type, handler)` | Hook | Standalone: subscribe to incoming events with automatic cleanup |
| `useSend<Send>()` | Hook | Standalone: stable `send` function |
| `TypedChannelHooks<Send, Receive>` | Type | Return type of `createChannelHooks` |
| `ChannelHooksOptions` | Type | Options for `createChannelHooks` |

### Type Re-exports
Expand Down
21 changes: 12 additions & 9 deletions docs/content/docs/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ TypeScript types provide compile-time safety on the host side, but they are eras

```ts
// Your schema says counter is a number...
const schemas = { counter: z.number() };
const schemas = { client: { counter: z.number() }, host: {} };

// ...but the webview can send anything
__channel__.send("counter", "not a number");
Expand All @@ -213,9 +213,12 @@ import { createChannel } from "@nativewindow/ipc";

const ch = createChannel(win, {
schemas: {
"user-click": z.object({ x: z.number(), y: z.number() }),
counter: z.number().finite(),
message: z.string().max(1024),
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);
Expand All @@ -242,7 +245,7 @@ This means:
- **Typed channels do not replace validation** — they provide ergonomic type safety for development, not a security boundary
- **Schema validation catches malformed payloads** — all incoming messages are validated against the schemas before reaching your handlers
- **Non-envelope messages are ignored** — the typed channel only dispatches messages matching the `{ $ch, p }` envelope format
- **Strict event allowlist** — messages with a `$ch` value that does not match any key in the schemas map are silently dropped, even if a handler was somehow registered for that type
- **Strict event allowlist** — messages with a `$ch` value that does not match any key in the receiving side's schemas are silently dropped, even if a handler was somehow registered for that type

### IPC Bridge Hardening

Expand Down Expand Up @@ -274,7 +277,7 @@ At the IPC layer, a configurable `maxMessageSize` option (default: **1 MB**) is

```ts
const ch = createChannel(win, {
schemas: { ping: z.string() },
schemas: { host: {}, client: { ping: z.string() } },
maxMessageSize: 512_000, // 512 KB
});
```
Expand All @@ -287,7 +290,7 @@ The `rateLimit` option in `ChannelOptions` limits how many incoming IPC messages

```ts
const ch = createChannel(win, {
schemas: { ping: z.string() },
schemas: { host: {}, client: { ping: z.string() } },
rateLimit: 100, // max 100 messages per second
});
```
Expand All @@ -302,7 +305,7 @@ The `channelId` option in `ChannelOptions` adds a namespace prefix to all `$ch`

```ts
const ch = createChannel(win, {
schemas: { ping: z.string() },
schemas: { host: {}, client: { ping: z.string() } },
channelId: true, // auto-generate a random 8-character nonce
});
```
Expand All @@ -322,7 +325,7 @@ When loading external URLs, you can restrict which pages receive the IPC client

```ts
const ch = createChannel(win, {
schemas: { ping: z.string() },
schemas: { host: { status: z.string() }, client: { ping: z.string() } },
trustedOrigins: ["https://myapp.com"],
});
```
Expand Down
Loading