diff --git a/docs/content/docs/ipc.mdx b/docs/content/docs/ipc.mdx index 0ef5587..b84a1e1 100644 --- a/docs/content/docs/ipc.mdx +++ b/docs/content/docs/ipc.mdx @@ -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 @@ -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}`); @@ -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` @@ -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`: +Both `createChannel` and `createWindow` return an object implementing `TypedChannel`: ```ts -interface TypedChannel { - send(...args: SendArgs): void; - on(type: K, handler: (payload: T[K]) => void): void; - off(type: K, handler: (payload: T[K]) => void): void; +interface TypedChannel { + send(...args: SendArgs): void; + on(type: K, handler: (payload: Receive[K]) => void): void; + off(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` returned by `createChannel`/`createWindow` extends this with a `readonly window` property. +The `NativeWindowChannel` returned by `createChannel`/`createWindow` extends this with a `readonly window` property. ## Webview Side @@ -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; }); ``` @@ -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 @@ -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) => { /* ... */ @@ -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 | @@ -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); @@ -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"], }); ``` diff --git a/docs/content/docs/react.mdx b/docs/content/docs/react.mdx index 5b352ed..36dc056 100644 --- a/docs/content/docs/react.mdx +++ b/docs/content/docs/react.mdx @@ -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(), + }, }); ``` @@ -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; }); @@ -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); @@ -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", @@ -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(); - useChannelEvent("title", (t) => { + const send = useSend(); + useChannelEvent("title", (t) => { document.title = t; }); return ; @@ -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 | @@ -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` | Type | Props interface for the standalone provider | -| `useChannel()` | Hook | Standalone: access the typed channel from context | -| `useChannelEvent(type, handler)` | Hook | Standalone: subscribe to events with automatic cleanup | -| `useSend()` | Hook | Standalone: stable `send` function | -| `TypedChannelHooks` | Type | Return type of `createChannelHooks` | +| `ChannelProviderProps` | Type | Props interface for the standalone provider | +| `useChannel()` | Hook | Standalone: access the typed channel from context | +| `useChannelEvent(type, handler)` | Hook | Standalone: subscribe to incoming events with automatic cleanup | +| `useSend()` | Hook | Standalone: stable `send` function | +| `TypedChannelHooks` | Type | Return type of `createChannelHooks` | | `ChannelHooksOptions` | Type | Options for `createChannelHooks` | ### Type Re-exports diff --git a/docs/content/docs/security.mdx b/docs/content/docs/security.mdx index ff1ec54..803faeb 100644 --- a/docs/content/docs/security.mdx +++ b/docs/content/docs/security.mdx @@ -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"); @@ -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); @@ -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 @@ -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 }); ``` @@ -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 }); ``` @@ -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 }); ``` @@ -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"], }); ``` diff --git a/docs/content/docs/use-cases.mdx b/docs/content/docs/use-cases.mdx index e3cd520..080f922 100644 --- a/docs/content/docs/use-cases.mdx +++ b/docs/content/docs/use-cases.mdx @@ -17,8 +17,12 @@ const ch = createWindow( { title: "Stream Deck Companion", width: 720, height: 480 }, { schemas: { - keyPress: z.object({ row: z.number(), col: z.number() }), - setKeyImage: z.object({ key: z.number(), src: z.string() }), + host: { + keyPress: z.object({ row: z.number(), col: z.number() }), + }, + client: { + setKeyImage: z.object({ key: z.number(), src: z.string() }), + }, }, }, ); @@ -61,7 +65,7 @@ Many CLI tools produce output that is better understood visually — markdown re import { z } from "zod"; import { createWindow } from "@nativewindow/ipc"; -const ch = createWindow({ title: "Markdown Preview" }, { schemas: { render: z.string() } }); +const ch = createWindow({ title: "Markdown Preview" }, { schemas: { host: { render: z.string() }, client: {} } }); ch.window.loadHtml(` @@ -98,9 +102,13 @@ const ch = createWindow( { title: "Notes" }, { schemas: { - saveNote: z.object({ id: z.string(), content: z.string() }), - loadNotes: z.object({}), - notesList: z.array(z.object({ id: z.string(), content: z.string() })), + host: { + notesList: z.array(z.object({ id: z.string(), content: z.string() })), + }, + client: { + saveNote: z.object({ id: z.string(), content: z.string() }), + loadNotes: z.object({}), + }, }, }, ); @@ -132,8 +140,12 @@ const ch = createWindow( { title: "Device Monitor" }, { schemas: { - sensorData: z.object({ temp: z.number(), humidity: z.number() }), - sendCommand: z.string(), + host: { + sensorData: z.object({ temp: z.number(), humidity: z.number() }), + }, + client: { + sendCommand: z.string(), + }, }, }, ); @@ -181,8 +193,11 @@ const ch = createWindow( { title: "Deploy Progress", width: 500, height: 300 }, { schemas: { - progress: z.object({ step: z.string(), pct: z.number() }), - done: z.object({ success: z.boolean(), message: z.string() }), + host: { + progress: z.object({ step: z.string(), pct: z.number() }), + done: z.object({ success: z.boolean(), message: z.string() }), + }, + client: {}, }, }, ); diff --git a/packages/ipc/CHANGELOG.md b/packages/ipc/CHANGELOG.md index 5b6a9a0..3d6148e 100644 --- a/packages/ipc/CHANGELOG.md +++ b/packages/ipc/CHANGELOG.md @@ -1,5 +1,11 @@ # @nativewindow/ipc +## 0.2.0 + +### Minor Changes + +- Add host/client schemas + ## 0.1.1 -- initial release \ No newline at end of file +- initial release diff --git a/packages/ipc/client.ts b/packages/ipc/client.ts index 6968141..f250af6 100644 --- a/packages/ipc/client.ts +++ b/packages/ipc/client.ts @@ -6,10 +6,14 @@ * 1. **Imported** in a bundled webview app (shares node_modules): * ```ts * import { createChannelClient } from "native-window-ipc/client"; - * type Events = { counter: number; title: string }; - * const channel = createChannelClient(); - * channel.send("counter", 42); - * channel.on("title", (t) => document.title = t); + * const channel = createChannelClient({ + * schemas: { + * host: { title: z.string() }, + * client: { counter: z.number() }, + * }, + * }); + * channel.send("counter", 42); // only client events + * channel.on("title", (t) => {}); // only host events * ``` * * 2. **Auto-injected** by the host-side `createChannel()`. @@ -23,11 +27,12 @@ import type { SchemaMap, SchemaLike, InferSchemaMap, -} from "."; + EventMap, +} from "./index.ts"; declare global { interface Window { - __channel__?: TypedChannel; + __channel__?: TypedChannel; __native_message__?: (msg: string) => void; __native_message_listeners__?: { add(fn: (msg: string) => void): void; @@ -79,20 +84,27 @@ function decode(raw: string): Envelope | null { /** * Options for {@link createChannelClient}. - * The `schemas` field is required — it provides both TypeScript types - * and runtime validation for incoming payloads from the host. + * The `schemas` field is required — it uses directional groups matching + * the host-side `createChannel` options. * * @example * ```ts * import { z } from "zod"; * const ch = createChannelClient({ - * schemas: { counter: z.number(), title: z.string() }, + * schemas: { + * host: { "update-title": z.string() }, + * client: { "user-click": z.object({ x: z.number(), y: z.number() }) }, + * }, * }); * ``` */ -export interface ChannelClientOptions { - /** Schemas for incoming events. Provides types and runtime validation. */ - schemas: S; +export interface ChannelClientOptions { + /** + * Directional schemas for the channel. + * - `host`: events the host sends to the client (validated on receive). + * - `client`: events the client sends to the host (type-checked on send). + */ + schemas: { host: H; client: C }; /** * Called when an incoming payload fails schema validation. * If not provided, failed payloads are silently dropped. @@ -119,6 +131,9 @@ function validatePayload( * Create a typed channel client for use inside the webview. * Call this once; it hooks into the native IPC bridge. * + * The client sends events defined by the `client` schemas and receives + * events defined by the `host` schemas — the inverse of the host side. + * * @example * ```ts * import { z } from "zod"; @@ -126,20 +141,23 @@ function validatePayload( * * const ch = createChannelClient({ * schemas: { - * "user-click": z.object({ x: z.number(), y: z.number() }), - * "update-title": z.string(), + * host: { "update-title": z.string() }, + * client: { "user-click": z.object({ x: z.number(), y: z.number() }) }, * }, * }); * - * ch.on("update-title", (title) => { + * ch.send("user-click", { x: 10, y: 20 }); // only client events + * ch.on("update-title", (title) => { // only host events * document.title = title; * }); * ``` */ -export function createChannelClient( - options: ChannelClientOptions, -): TypedChannel> { +export function createChannelClient( + options: ChannelClientOptions, +): TypedChannel, InferSchemaMap> { const { schemas, onValidationError } = options; + const hostSchemas = schemas.host; + const clientSchemas = schemas.client; // Save Array.prototype methods to prevent prototype pollution attacks const _push = Array.prototype.push; @@ -148,17 +166,18 @@ export function createChannelClient( const listeners = new Map void>>(); - const channel: TypedChannel> = { - send & string>( - ...args: SendArgs, K> + // Client sends C events (client schemas), receives H events (host schemas) + const channel: TypedChannel, InferSchemaMap> = { + send & string>( + ...args: SendArgs, K> ): void { const [type, payload] = args; window.ipc.postMessage(encode(type, payload)); }, - on & string>( + on & string>( type: K, - handler: (payload: InferSchemaMap[K]) => void, + handler: (payload: InferSchemaMap[K]) => void, ): void { let set = listeners.get(type); if (!set) { @@ -168,9 +187,9 @@ export function createChannelClient( set.add(handler as (payload: any) => void); }, - off & string>( + off & string>( type: K, - handler: (payload: InferSchemaMap[K]) => void, + handler: (payload: InferSchemaMap[K]) => void, ): void { const set = listeners.get(type); if (set) set.delete(handler as (payload: any) => void); @@ -188,8 +207,8 @@ export function createChannelClient( value(msg: string): void { const env = decode(msg); if (env) { - // Drop messages whose $ch is not a known schema key (strict allowlist) - if (!(env.$ch in schemas)) { + // Drop messages whose $ch is not a known host schema key (strict allowlist) + if (!(env.$ch in hostSchemas)) { // Forward to external listeners — not a recognized channel message for (const fn of externalListeners) { try { @@ -201,8 +220,8 @@ export function createChannelClient( } const set = listeners.get(env.$ch); if (set) { - // Validate payload against the schema for this channel - const schema = schemas[env.$ch]; + // Validate payload against the host schema for this channel + const schema = hostSchemas[env.$ch]; let validatedPayload: unknown = env.p; if (schema) { const result = validatePayload(schema, env.p); diff --git a/packages/ipc/index.ts b/packages/ipc/index.ts index a649125..9d02b2a 100644 --- a/packages/ipc/index.ts +++ b/packages/ipc/index.ts @@ -116,18 +116,27 @@ export type SendArgs = [T[K]] ex ? [type: K] | [type: K, payload: T[K]] : [type: K, payload: T[K]]; -/** Typed channel interface (shared shape for both host and webview sides). */ -export interface TypedChannel { - /** Send a typed message. */ - send(...args: SendArgs): void; - /** Register a handler for a typed message. */ - on(type: K, handler: (payload: T[K]) => void): void; - /** Remove a handler for a typed message. */ - off(type: K, handler: (payload: T[K]) => void): void; +/** + * Typed channel interface with separate Send and Receive event maps. + * `Send` determines which events are available via `send()`, while + * `Receive` determines which events are available via `on()` / `off()`. + */ +export interface TypedChannel { + /** Send a typed message. Only events from the Send map are allowed. */ + send(...args: SendArgs): void; + /** Register a handler for an incoming event. Only events from the Receive map are allowed. */ + on(type: K, handler: (payload: Receive[K]) => void): void; + /** Remove a handler for an incoming event. Only events from the Receive map are allowed. */ + off(type: K, handler: (payload: Receive[K]) => void): void; } -/** Host-side channel wrapping a NativeWindow. */ -export interface NativeWindowChannel extends TypedChannel { +/** + * Host-side channel wrapping a NativeWindow. + * The host sends events defined by the `host` schemas and receives + * events defined by the `client` schemas. + */ +export interface NativeWindowChannel + extends TypedChannel { /** The underlying NativeWindow instance. */ readonly window: NativeWindow; } @@ -135,20 +144,28 @@ export interface NativeWindowChannel extends TypedChannel /** * Options for {@link createChannel}. * The `schemas` field is required — it provides both TypeScript types - * and runtime validation for each event. + * and runtime validation for each event direction. * * @example * ```ts * import { z } from "zod"; * const ch = createChannel(win, { - * schemas: { ping: z.string(), pong: z.number() }, + * schemas: { + * host: { "update-title": z.string() }, + * client: { "user-click": z.object({ x: z.number(), y: z.number() }) }, + * }, * }); - * ch.send("ping", "hello"); // typed from schema + * ch.send("update-title", "Hello"); // only host events + * ch.on("user-click", (p) => {}); // only client events * ``` */ -export interface ChannelOptions { - /** Schemas for each event. Provides both TypeScript types and runtime validation. */ - schemas: S; +export interface ChannelOptions { + /** + * Directional schemas for the channel. + * - `host`: events the host sends to the client. + * - `client`: events the client sends to the host. + */ + schemas: { host: H; client: C }; /** Inject the client script into the webview automatically. Default: true */ injectClient?: boolean; /** @@ -175,7 +192,10 @@ export interface ChannelOptions { * @example * ```ts * createChannel(win, { - * schemas: { ping: z.string() }, + * schemas: { + * host: { ping: z.string() }, + * client: { pong: z.number() }, + * }, * trustedOrigins: ["https://myapp.com", "https://cdn.myapp.com"], * }); * ``` @@ -372,6 +392,13 @@ try{Object.defineProperty(window,'__channel__',{value:Object.freeze(ch),writable * validation for each event. Compatible with Zod v4, Valibot v1, and * any schema library implementing the `safeParse()` interface. * + * The `schemas` option uses directional groups: + * - `host`: events the host sends to the client. + * - `client`: events the client sends to the host. + * + * On the returned channel, `send()` only accepts `host` event types and + * `on()`/`off()` only accept `client` event types. + * * @security **Origin restriction:** When `trustedOrigins` is configured, * both client script injection and incoming IPC messages are restricted to * pages whose URL origin matches the whitelist. The native `onMessage` @@ -385,16 +412,19 @@ try{Object.defineProperty(window,'__channel__',{value:Object.freeze(ch),writable * import { createChannel } from "native-window-ipc"; * * const ch = createChannel(win, { - * schemas: { ping: z.string(), pong: z.number() }, + * schemas: { + * host: { "update-title": z.string() }, + * client: { "user-click": z.object({ x: z.number(), y: z.number() }) }, + * }, * }); - * ch.send("ping", "hello"); // typed from schema - * ch.on("pong", (n) => {}); // n: number + * ch.send("update-title", "Hello"); // only host events + * ch.on("user-click", (p) => {}); // only client events, p: { x: number; y: number } * ``` */ -export function createChannel( +export function createChannel( win: NativeWindow, - options: ChannelOptions, -): NativeWindowChannel> { + options: ChannelOptions, +): NativeWindowChannel, InferSchemaMap> { const { schemas, injectClient = true, @@ -406,6 +436,9 @@ export function createChannel( channelId: channelIdOpt, } = options; + const hostSchemas = schemas.host; + const clientSchemas = schemas.client; + // Resolve channelId: true → cryptographically random nonce, string → as-is, undefined → "" const channelId = channelIdOpt === true @@ -462,11 +495,11 @@ export function createChannel( const set = listeners.get(eventType); if (!set) return; - // Drop messages whose event type is not a known schema key (strict allowlist) - if (!(eventType in schemas)) return; + // Drop messages whose event type is not a known client schema key (strict allowlist) + if (!(eventType in clientSchemas)) return; - // Validate payload against the schema for this channel - const schema = schemas[eventType]; + // Validate payload against the client schema for this channel + const schema = clientSchemas[eventType]; let validatedPayload: unknown = env.p; if (schema) { const result = validatePayload(schema, env.p); @@ -510,8 +543,8 @@ export function createChannel( return { window: win, - send & string>( - ...args: SendArgs, K> + send & string>( + ...args: SendArgs, K> ): void { const [type, payload] = args; // Note: Outgoing payloads are not validated at runtime — only TypeScript @@ -520,12 +553,12 @@ export function createChannel( win.postMessage(encode(prefixCh(type), payload)); }, - on & string>( + on & string>( type: K, - handler: (payload: InferSchemaMap[K]) => void, + handler: (payload: InferSchemaMap[K]) => void, ): void { // Runtime schema key validation — reject unrecognized event types - if (!(type in schemas)) return; + if (!(type in clientSchemas)) return; let set = listeners.get(type); if (!set) { set = new Set(); @@ -535,9 +568,9 @@ export function createChannel( set.add(handler as (payload: any) => void); }, - off & string>( + off & string>( type: K, - handler: (payload: InferSchemaMap[K]) => void, + handler: (payload: InferSchemaMap[K]) => void, ): void { const set = listeners.get(type); if (set) set.delete(handler as (payload: any) => void); @@ -557,16 +590,21 @@ export function createChannel( * * const ch = createWindow( * { title: "My App" }, - * { schemas: { counter: z.number(), title: z.string() } }, + * { + * schemas: { + * host: { "update-title": z.string() }, + * client: { "user-click": z.object({ x: z.number(), y: z.number() }) }, + * }, + * }, * ); - * ch.send("counter", 42); // typed from schema - * ch.window.loadHtml("..."); + * ch.send("update-title", "Hello"); // only host events + * ch.on("user-click", (p) => {}); // only client events * ``` */ -export function createWindow( +export function createWindow( windowOptions: WindowOptions | undefined, - channelOptions: ChannelOptions, -): NativeWindowChannel> { + channelOptions: ChannelOptions, +): NativeWindowChannel, InferSchemaMap> { const win = new NativeWindow(windowOptions); return createChannel(win, channelOptions); } diff --git a/packages/ipc/package.json b/packages/ipc/package.json index a343857..553879e 100644 --- a/packages/ipc/package.json +++ b/packages/ipc/package.json @@ -1,6 +1,6 @@ { "name": "@nativewindow/ipc", - "version": "0.1.1", + "version": "0.2.0", "description": "Typesafe IPC channels for native-window (alpha)", "homepage": "https://nativewindow.fcannizzaro.com", "bugs": { diff --git a/packages/ipc/tests/channel.test.ts b/packages/ipc/tests/channel.test.ts index aee8c1d..1d4dc2c 100644 --- a/packages/ipc/tests/channel.test.ts +++ b/packages/ipc/tests/channel.test.ts @@ -89,9 +89,15 @@ const pointSchema = mockSchema( ); const testSchemas = { - ping: stringSchema, - pong: numberSchema, - data: pointSchema, + host: { + ping: stringSchema, + data: pointSchema, + }, + client: { + ping: stringSchema, + pong: numberSchema, + data: pointSchema, + }, }; // ── Tests ────────────────────────────────────────────────────────── @@ -408,7 +414,7 @@ describe("schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, }); const received: string[] = []; @@ -422,7 +428,7 @@ describe("schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, }); const received: string[] = []; @@ -438,7 +444,7 @@ describe("schema validation", () => { // Only define schema for "ping", not "pong" const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema, pong: numberSchema }, + schemas: { host: {}, client: { ping: stringSchema, pong: numberSchema } }, }); // Send to an event not in schemas (simulated via raw envelope) @@ -453,7 +459,7 @@ describe("schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, }); const received: unknown[] = []; @@ -469,7 +475,7 @@ describe("schema validation", () => { const errors: { type: string; payload: unknown }[] = []; const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, onValidationError: (type, payload) => errors.push({ type, payload }), }); @@ -483,7 +489,7 @@ describe("schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, maxMessageSize: 50, }); @@ -501,7 +507,7 @@ describe("schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: pointSchema }, + schemas: { host: {}, client: { ping: pointSchema } }, }); const received: any[] = []; @@ -520,7 +526,7 @@ describe("schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, }); const received: string[] = []; @@ -762,7 +768,7 @@ describe("Zod schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: z.string(), pong: z.number() }, + schemas: { host: {}, client: { ping: z.string(), pong: z.number() } }, }); const received: string[] = []; @@ -776,7 +782,7 @@ describe("Zod schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: z.string() }, + schemas: { host: {}, client: { ping: z.string() } }, }); const received: string[] = []; @@ -791,7 +797,7 @@ describe("Zod schema validation", () => { const errors: { type: string; payload: unknown }[] = []; const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: z.string() }, + schemas: { host: {}, client: { ping: z.string() } }, onValidationError: (type, payload) => errors.push({ type, payload }), }); @@ -806,7 +812,8 @@ describe("Zod schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - data: z.object({ x: z.number(), y: z.number() }), + host: {}, + client: { data: z.object({ x: z.number(), y: z.number() }) }, }, }); @@ -827,9 +834,12 @@ describe("Zod schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - ping: z.string(), - pong: z.number(), - data: z.object({ x: z.number(), y: z.number() }), + host: { + ping: z.string(), + pong: z.number(), + data: z.object({ x: z.number(), y: z.number() }), + }, + client: {}, }, }); @@ -851,8 +861,8 @@ describe("Zod schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - ping: z.string(), - pong: z.number(), + host: {}, + client: { ping: z.string(), pong: z.number() }, }, }); @@ -900,8 +910,11 @@ describe("Valibot schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - ping: valibotAdapter(v.string()), - pong: valibotAdapter(v.number()), + host: {}, + client: { + ping: valibotAdapter(v.string()), + pong: valibotAdapter(v.number()), + }, }, }); @@ -916,7 +929,7 @@ describe("Valibot schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: valibotAdapter(v.string()) }, + schemas: { host: {}, client: { ping: valibotAdapter(v.string()) } }, }); const received: string[] = []; @@ -931,7 +944,7 @@ describe("Valibot schema validation", () => { const errors: { type: string; payload: unknown }[] = []; const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: valibotAdapter(v.string()) }, + schemas: { host: {}, client: { ping: valibotAdapter(v.string()) } }, onValidationError: (type, payload) => errors.push({ type, payload }), }); @@ -946,7 +959,8 @@ describe("Valibot schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - data: valibotAdapter(v.object({ x: v.number(), y: v.number() })), + host: {}, + client: { data: valibotAdapter(v.object({ x: v.number(), y: v.number() })) }, }, }); @@ -967,9 +981,12 @@ describe("Valibot schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - ping: valibotAdapter(v.string()), - pong: valibotAdapter(v.number()), - data: valibotAdapter(v.object({ x: v.number(), y: v.number() })), + host: { + ping: valibotAdapter(v.string()), + pong: valibotAdapter(v.number()), + data: valibotAdapter(v.object({ x: v.number(), y: v.number() })), + }, + client: {}, }, }); @@ -991,8 +1008,11 @@ describe("Valibot schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - ping: valibotAdapter(v.string()), - pong: valibotAdapter(v.number()), + host: {}, + client: { + ping: valibotAdapter(v.string()), + pong: valibotAdapter(v.number()), + }, }, }); @@ -1040,8 +1060,11 @@ describe("ArkType schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - ping: arktypeAdapter(type("string")), - pong: arktypeAdapter(type("number")), + host: {}, + client: { + ping: arktypeAdapter(type("string")), + pong: arktypeAdapter(type("number")), + }, }, }); @@ -1056,7 +1079,7 @@ describe("ArkType schema validation", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: arktypeAdapter(type("string")) }, + schemas: { host: {}, client: { ping: arktypeAdapter(type("string")) } }, }); const received: string[] = []; @@ -1071,7 +1094,7 @@ describe("ArkType schema validation", () => { const errors: { type: string; payload: unknown }[] = []; const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: arktypeAdapter(type("string")) }, + schemas: { host: {}, client: { ping: arktypeAdapter(type("string")) } }, onValidationError: (tp, payload) => errors.push({ type: tp, payload }), }); @@ -1086,7 +1109,8 @@ describe("ArkType schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - data: arktypeAdapter(type({ x: "number", y: "number" })), + host: {}, + client: { data: arktypeAdapter(type({ x: "number", y: "number" })) }, }, }); @@ -1107,9 +1131,12 @@ describe("ArkType schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - ping: arktypeAdapter(type("string")), - pong: arktypeAdapter(type("number")), - data: arktypeAdapter(type({ x: "number", y: "number" })), + host: { + ping: arktypeAdapter(type("string")), + pong: arktypeAdapter(type("number")), + data: arktypeAdapter(type({ x: "number", y: "number" })), + }, + client: {}, }, }); @@ -1131,8 +1158,11 @@ describe("ArkType schema validation", () => { const ch = createChannel(win as any, { injectClient: false, schemas: { - ping: arktypeAdapter(type("string")), - pong: arktypeAdapter(type("number")), + host: {}, + client: { + ping: arktypeAdapter(type("string")), + pong: arktypeAdapter(type("number")), + }, }, }); @@ -1163,7 +1193,7 @@ describe("void payload", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: voidSchema, pong: numberSchema }, + schemas: { host: { ping: voidSchema, pong: numberSchema }, client: {} }, }); // void event — omit payload @@ -1178,7 +1208,7 @@ describe("void payload", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: voidSchema }, + schemas: { host: { ping: voidSchema }, client: {} }, }); // void event — explicit undefined payload @@ -1193,7 +1223,7 @@ describe("void payload", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { pong: numberSchema }, + schemas: { host: { pong: numberSchema }, client: {} }, }); ch.send("pong", 42); @@ -1207,7 +1237,7 @@ describe("void payload", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: voidSchema }, + schemas: { host: {}, client: { ping: voidSchema } }, }); const calls: unknown[] = []; @@ -1231,7 +1261,7 @@ describe("channelId namespace", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: { ping: stringSchema }, client: {} }, channelId: "ns1", }); @@ -1246,7 +1276,7 @@ describe("channelId namespace", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, channelId: "ns1", }); @@ -1266,7 +1296,7 @@ describe("channelId namespace", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, channelId: "ns1", }); @@ -1281,7 +1311,7 @@ describe("channelId namespace", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: { ping: stringSchema }, client: {} }, channelId: true, }); @@ -1297,7 +1327,7 @@ describe("channelId namespace", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: { ping: stringSchema }, client: {} }, }); ch.send("ping", "hello"); @@ -1310,7 +1340,7 @@ describe("channelId namespace", () => { const win = createMockWindow(); createChannel(win as any, { injectClient: true, - schemas: { ping: stringSchema }, + schemas: { host: { ping: stringSchema }, client: {} }, channelId: "myns", }); @@ -1373,7 +1403,7 @@ describe("schema transform support", () => { }; const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: transformSchema }, + schemas: { host: {}, client: { ping: transformSchema } }, }); const received: unknown[] = []; @@ -1387,7 +1417,7 @@ describe("schema transform support", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, }); const received: unknown[] = []; @@ -1405,7 +1435,7 @@ describe("rate limiting", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, rateLimit: 3, }); @@ -1425,7 +1455,7 @@ describe("rate limiting", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, }); const received: string[] = []; @@ -1442,7 +1472,7 @@ describe("rate limiting", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, rateLimit: 0, }); @@ -1461,7 +1491,7 @@ describe("listener limits", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, }); const received: unknown[] = []; @@ -1476,7 +1506,7 @@ describe("listener limits", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, maxListenersPerEvent: 2, }); @@ -1493,7 +1523,7 @@ describe("listener limits", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema, pong: numberSchema }, + schemas: { host: {}, client: { ping: stringSchema, pong: numberSchema } }, maxListenersPerEvent: 1, }); @@ -1513,7 +1543,7 @@ describe("listener limits", () => { const win = createMockWindow(); const ch = createChannel(win as any, { injectClient: false, - schemas: { ping: stringSchema }, + schemas: { host: {}, client: { ping: stringSchema } }, }); const calls: number[] = []; diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index e4bf447..7b41509 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,16 @@ # @nativewindow/react +## 1.0.0 + +### Minor Changes + +- Add host/client schemas + +### Patch Changes + +- Updated dependencies + - @nativewindow/ipc@0.2.0 + ## 0.1.1 - initial release diff --git a/packages/react/index.ts b/packages/react/index.ts index 1231caa..4da2db4 100644 --- a/packages/react/index.ts +++ b/packages/react/index.ts @@ -15,8 +15,8 @@ * * const { ChannelProvider, useChannel, useChannelEvent, useSend } = * createChannelHooks({ - * counter: z.number(), - * title: z.string(), + * host: { title: z.string() }, + * client: { counter: z.number() }, * }); * * function App() { @@ -69,7 +69,7 @@ import { createChannelClient } from "@nativewindow/ipc/client"; // ── Context (internal) ───────────────────────────────────────────── /** @internal React context holding the channel instance. */ -const ChannelContext = createContext | null>(null); +const ChannelContext = createContext | null>(null); // ── ChannelProvider ──────────────────────────────────────────────── @@ -78,14 +78,18 @@ const ChannelContext = createContext | null>(null); * * @example * ```tsx - * + * * * * ``` */ -export interface ChannelProviderProps { - /** Schemas for each event. Provides both TypeScript types and runtime validation. */ - schemas: S; +export interface ChannelProviderProps { + /** + * Directional schemas for the channel. + * - `host`: events the host sends to the client (validated on receive). + * - `client`: events the client sends to the host (type-checked on send). + */ + schemas: { host: H; client: C }; /** * Called when an incoming payload fails schema validation. * If not provided, failed payloads are silently dropped. @@ -108,8 +112,8 @@ export interface ChannelProviderProps { * import { ChannelProvider } from "@nativewindow/react"; * * const schemas = { - * counter: z.number(), - * title: z.string(), + * host: { title: z.string() }, + * client: { counter: z.number() }, * }; * * function Root() { @@ -121,9 +125,11 @@ export interface ChannelProviderProps { * } * ``` */ -export function ChannelProvider(props: ChannelProviderProps): ReactNode { +export function ChannelProvider( + props: ChannelProviderProps, +): ReactNode { const { schemas, onValidationError, children } = props; - const channelRef = useRef> | null>(null); + const channelRef = useRef, InferSchemaMap> | null>(null); if (channelRef.current === null) { channelRef.current = createChannelClient({ schemas, onValidationError }); @@ -140,43 +146,53 @@ export function ChannelProvider(props: ChannelProviderProps * Must be called inside a {@link ChannelProvider}. Throws if the * provider is missing. * + * @typeParam Send - Events this side can send (client events). + * @typeParam Receive - Events this side can receive (host events). + * * @example * ```tsx * import { useChannel } from "@nativewindow/react"; * - * type Events = { counter: number; title: string }; + * type ClientEvents = { counter: number }; + * type HostEvents = { title: string }; * * function StatusBar() { - * const channel = useChannel(); + * const channel = useChannel(); * channel.send("counter", 1); * } * ``` */ -export function useChannel(): TypedChannel { +export function useChannel< + Send extends EventMap = EventMap, + Receive extends EventMap = EventMap, +>(): TypedChannel { const channel = useContext(ChannelContext); if (channel === null) { throw new Error("useChannel() must be used inside a ."); } - return channel as TypedChannel; + return channel as TypedChannel; } // ── useChannelEvent ──────────────────────────────────────────────── /** - * Subscribe to a specific IPC event type with automatic cleanup. + * Subscribe to a specific incoming IPC event type with automatic cleanup. * * The handler is stored in a ref to avoid re-subscribing when the * handler function identity changes between renders. The subscription * itself only re-runs when `type` changes. * + * @typeParam Receive - The event map for incoming (receivable) events. + * @typeParam K - The specific event key to subscribe to. + * * @example * ```tsx * import { useChannelEvent } from "@nativewindow/react"; * - * type Events = { title: string }; + * type HostEvents = { title: string }; * * function TitleDisplay() { - * useChannelEvent("title", (title) => { + * useChannelEvent("title", (title) => { * document.title = title; * }); * return null; @@ -184,17 +200,17 @@ export function useChannel(): TypedChannel { * ``` */ export function useChannelEvent< - T extends EventMap = EventMap, - K extends keyof T & string = keyof T & string, ->(type: K, handler: (payload: T[K]) => void): void { - const channel = useChannel(); + Receive extends EventMap = EventMap, + K extends keyof Receive & string = keyof Receive & string, +>(type: K, handler: (payload: Receive[K]) => void): void { + const channel = useChannel(); const handlerRef = useRef(handler); // Keep the ref current without re-subscribing handlerRef.current = handler; useEffect(() => { - const stableHandler = (payload: T[K]): void => { + const stableHandler = (payload: Receive[K]): void => { handlerRef.current(payload); }; @@ -213,25 +229,27 @@ export function useChannelEvent< * A convenience wrapper around `useChannel().send`. The returned * function has a stable identity (does not change between renders). * + * @typeParam Send - The event map for outgoing (sendable) events. + * * @example * ```tsx * import { useSend } from "@nativewindow/react"; * - * type Events = { counter: number; title: string }; + * type ClientEvents = { counter: number }; * * function Counter() { - * const send = useSend(); + * const send = useSend(); * return ; * } * ``` */ -export function useSend(): ( - ...args: SendArgs +export function useSend(): ( + ...args: SendArgs ) => void { - const channel = useChannel(); + const channel = useChannel(); return useCallback( - (...args: SendArgs): void => { + (...args: SendArgs): void => { channel.send(...args); }, [channel], @@ -262,22 +280,29 @@ export interface ChannelHooksOptions { * The set of pre-typed React hooks and provider returned by * {@link createChannelHooks}. * - * All hooks are bound to the same internal context and typed to `T`, - * so event names and payload types are inferred automatically without - * requiring generic type parameters at the call site. + * All hooks are bound to the same internal context and typed with + * separate Send/Receive maps, so event names and payload types are + * inferred automatically without requiring generic type parameters + * at the call site. + * + * @typeParam Send - Events the client sends to the host. + * @typeParam Receive - Events the client receives from the host. */ -export interface TypedChannelHooks { +export interface TypedChannelHooks { /** * Context provider that creates the channel client once. * Wrap your React app with this at the root. */ ChannelProvider: (props: { children: ReactNode }) => ReactNode; /** Access the typed channel from context. Throws if outside the provider. */ - useChannel: () => TypedChannel; - /** Subscribe to a typed event with automatic cleanup. */ - useChannelEvent: (type: K, handler: (payload: T[K]) => void) => void; - /** Returns a stable typed `send` function. */ - useSend: () => (...args: SendArgs) => void; + useChannel: () => TypedChannel; + /** Subscribe to a typed incoming (host) event with automatic cleanup. */ + useChannelEvent: ( + type: K, + handler: (payload: Receive[K]) => void, + ) => void; + /** Returns a stable typed `send` function for outgoing (client) events. */ + useSend: () => (...args: SendArgs) => void; } /** @@ -292,16 +317,16 @@ export interface TypedChannelHooks { * import { z } from "zod"; * import { createChannelHooks } from "@nativewindow/react"; * - * // Types are inferred: { counter: number; title: string } + * // Types are inferred from directional schemas * const { ChannelProvider, useChannel, useChannelEvent, useSend } = * createChannelHooks({ - * counter: z.number(), - * title: z.string(), + * host: { title: z.string() }, + * client: { counter: z.number() }, * }); * * function App() { - * const send = useSend(); // fully typed - * useChannelEvent("title", (t) => { // t: string + * const send = useSend(); // fully typed (client events) + * useChannelEvent("title", (t) => { // t: string (host events) * document.title = t; * }); * return ; @@ -316,17 +341,19 @@ export interface TypedChannelHooks { * } * ``` */ -export function createChannelHooks( - schemas: S, +export function createChannelHooks( + schemas: { host: H; client: C }, options?: ChannelHooksOptions, -): TypedChannelHooks> { - type T = InferSchemaMap; +): TypedChannelHooks, InferSchemaMap> { + // Client sends C events, receives H events + type Send = InferSchemaMap; + type Receive = InferSchemaMap; // Each factory call gets its own context — supports multiple channels - const HooksContext = createContext | null>(null); + const HooksContext = createContext | null>(null); function HooksProvider(props: { children: ReactNode }): ReactNode { - const channelRef = useRef | null>(null); + const channelRef = useRef | null>(null); if (channelRef.current === null) { channelRef.current = createChannelClient({ @@ -338,7 +365,7 @@ export function createChannelHooks( return createElement(HooksContext.Provider, { value: channelRef.current }, props.children); } - function hooks_useChannel(): TypedChannel { + function hooks_useChannel(): TypedChannel { const channel = useContext(HooksContext); if (channel === null) { throw new Error( @@ -348,16 +375,16 @@ export function createChannelHooks( return channel; } - function hooks_useChannelEvent( + function hooks_useChannelEvent( type: K, - handler: (payload: T[K]) => void, + handler: (payload: Receive[K]) => void, ): void { const channel = hooks_useChannel(); const handlerRef = useRef(handler); handlerRef.current = handler; useEffect(() => { - const stableHandler = (payload: T[K]): void => { + const stableHandler = (payload: Receive[K]): void => { handlerRef.current(payload); }; @@ -368,11 +395,11 @@ export function createChannelHooks( }, [channel, type]); } - function hooks_useSend(): (...args: SendArgs) => void { + function hooks_useSend(): (...args: SendArgs) => void { const channel = hooks_useChannel(); return useCallback( - (...args: SendArgs): void => { + (...args: SendArgs): void => { channel.send(...args); }, [channel], diff --git a/packages/react/package.json b/packages/react/package.json index 443f75c..e0dd9e8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@nativewindow/react", - "version": "0.1.1", + "version": "1.0.0", "description": "React bindings for native-window IPC (alpha)", "homepage": "https://nativewindow.fcannizzaro.com", "bugs": { diff --git a/packages/react/tests/hooks.test.ts b/packages/react/tests/hooks.test.ts index 3d1f1b8..20111ea 100644 --- a/packages/react/tests/hooks.test.ts +++ b/packages/react/tests/hooks.test.ts @@ -5,7 +5,7 @@ import type { TypedChannel } from "@nativewindow/ipc"; // ── Mock channel ────────────────────────────────────────────────── -interface MockChannel extends TypedChannel { +interface MockChannel extends TypedChannel { _listeners: Map void>>; _simulateEvent: (type: string, payload: unknown) => void; } @@ -60,7 +60,7 @@ import { createChannelClient } from "@nativewindow/ipc/client"; // ── Helpers ─────────────────────────────────────────────────────── -function createWrapper(schemas: Record) { +function createWrapper(schemas: { host: Record; client: Record }) { return function Wrapper({ children }: { children: ReactNode }) { return createElement(ChannelProvider, { schemas, children }); }; @@ -75,7 +75,7 @@ describe("ChannelProvider", () => { }); test("creates channel client once on mount", () => { - const schemas = { ping: mockSchema }; + const schemas = { host: { ping: mockSchema }, client: {} }; const wrapper = createWrapper(schemas); const { rerender } = renderHook(() => useChannel(), { wrapper }); @@ -85,7 +85,7 @@ describe("ChannelProvider", () => { }); test("passes schemas and onValidationError to createChannelClient", () => { - const schemas = { ping: mockSchema }; + const schemas = { host: { ping: mockSchema }, client: {} }; const onValidationError = vi.fn(); function Wrapper({ children }: { children: ReactNode }) { @@ -105,7 +105,7 @@ describe("ChannelProvider", () => { }); test("provides stable channel reference across re-renders", () => { - const schemas = { ping: mockSchema }; + const schemas = { host: { ping: mockSchema }, client: {} }; const wrapper = createWrapper(schemas); const { result, rerender } = renderHook(() => useChannel(), { wrapper }); @@ -124,7 +124,7 @@ describe("useChannel", () => { }); test("returns channel from context", () => { - const wrapper = createWrapper({ ping: mockSchema }); + const wrapper = createWrapper({ host: { ping: mockSchema }, client: {} }); const { result } = renderHook(() => useChannel(), { wrapper }); @@ -151,7 +151,7 @@ describe("useChannelEvent", () => { test("subscribes to event on mount", () => { const handler = vi.fn(); - const wrapper = createWrapper({ ping: mockSchema }); + const wrapper = createWrapper({ host: { ping: mockSchema }, client: {} }); renderHook(() => useChannelEvent("ping", handler), { wrapper }); @@ -161,7 +161,7 @@ describe("useChannelEvent", () => { test("unsubscribes on unmount", () => { const handler = vi.fn(); - const wrapper = createWrapper({ ping: mockSchema }); + const wrapper = createWrapper({ host: { ping: mockSchema }, client: {} }); const { unmount } = renderHook(() => useChannelEvent("ping", handler), { wrapper }); @@ -175,7 +175,7 @@ describe("useChannelEvent", () => { test("calls latest handler without re-subscribing", () => { const handler1 = vi.fn(); const handler2 = vi.fn(); - const wrapper = createWrapper({ ping: mockSchema }); + const wrapper = createWrapper({ host: { ping: mockSchema }, client: {} }); const { rerender } = renderHook(({ handler }) => useChannelEvent("ping", handler), { wrapper, @@ -196,7 +196,7 @@ describe("useChannelEvent", () => { test("re-subscribes when event type changes", () => { const handler = vi.fn(); - const wrapper = createWrapper({ ping: mockSchema, pong: mockSchema }); + const wrapper = createWrapper({ host: { ping: mockSchema, pong: mockSchema }, client: {} }); const { rerender } = renderHook( ({ type }: { type: string }) => useChannelEvent(type, handler), @@ -211,7 +211,7 @@ describe("useChannelEvent", () => { test("delivers event payload to handler", () => { const handler = vi.fn(); - const wrapper = createWrapper({ ping: mockSchema }); + const wrapper = createWrapper({ host: { ping: mockSchema }, client: {} }); renderHook(() => useChannelEvent("ping", handler), { wrapper }); @@ -228,7 +228,7 @@ describe("useSend", () => { }); test("returns a stable function across re-renders", () => { - const wrapper = createWrapper({ ping: mockSchema }); + const wrapper = createWrapper({ host: {}, client: { ping: mockSchema } }); const { result, rerender } = renderHook(() => useSend(), { wrapper }); const first = result.current; @@ -239,7 +239,7 @@ describe("useSend", () => { }); test("delegates to channel.send()", () => { - const wrapper = createWrapper({ ping: mockSchema }); + const wrapper = createWrapper({ host: {}, client: { ping: mockSchema } }); const { result } = renderHook(() => useSend(), { wrapper }); @@ -260,7 +260,7 @@ describe("createChannelHooks", () => { }); test("creates typed hooks from schemas", () => { - const hooks = createChannelHooks({ ping: mockSchema, pong: mockSchema }); + const hooks = createChannelHooks({ host: { ping: mockSchema, pong: mockSchema }, client: {} }); expect(hooks.ChannelProvider).toBeTypeOf("function"); expect(hooks.useChannel).toBeTypeOf("function"); @@ -269,7 +269,7 @@ describe("createChannelHooks", () => { }); test("provider creates channel client once", () => { - const schemas = { ping: mockSchema }; + const schemas = { host: { ping: mockSchema }, client: {} }; const hooks = createChannelHooks(schemas); function Wrapper({ children }: { children: ReactNode }) { @@ -289,7 +289,7 @@ describe("createChannelHooks", () => { }); test("passes onValidationError option to createChannelClient", () => { - const schemas = { ping: mockSchema }; + const schemas = { host: { ping: mockSchema }, client: {} }; const onValidationError = vi.fn(); const hooks = createChannelHooks(schemas, { onValidationError }); @@ -306,7 +306,7 @@ describe("createChannelHooks", () => { }); test("useChannel returns channel from context", () => { - const hooks = createChannelHooks({ ping: mockSchema }); + const hooks = createChannelHooks({ host: { ping: mockSchema }, client: {} }); function Wrapper({ children }: { children: ReactNode }) { return createElement(hooks.ChannelProvider, { children }); @@ -320,7 +320,7 @@ describe("createChannelHooks", () => { }); test("useChannel throws when used outside provider", () => { - const hooks = createChannelHooks({ ping: mockSchema }); + const hooks = createChannelHooks({ host: { ping: mockSchema }, client: {} }); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); expect(() => { @@ -333,7 +333,7 @@ describe("createChannelHooks", () => { }); test("useChannelEvent subscribes and unsubscribes", () => { - const hooks = createChannelHooks({ ping: mockSchema }); + const hooks = createChannelHooks({ host: { ping: mockSchema }, client: {} }); const handler = vi.fn(); function Wrapper({ children }: { children: ReactNode }) { @@ -354,7 +354,7 @@ describe("createChannelHooks", () => { }); test("useChannelEvent delivers payload to handler", () => { - const hooks = createChannelHooks({ ping: mockSchema }); + const hooks = createChannelHooks({ host: { ping: mockSchema }, client: {} }); const handler = vi.fn(); function Wrapper({ children }: { children: ReactNode }) { @@ -371,7 +371,7 @@ describe("createChannelHooks", () => { }); test("useSend delegates to channel.send()", () => { - const hooks = createChannelHooks({ ping: mockSchema }); + const hooks = createChannelHooks({ host: {}, client: { ping: mockSchema } }); function Wrapper({ children }: { children: ReactNode }) { return createElement(hooks.ChannelProvider, { children }); @@ -389,7 +389,7 @@ describe("createChannelHooks", () => { }); test("useSend returns a stable function across re-renders", () => { - const hooks = createChannelHooks({ ping: mockSchema }); + const hooks = createChannelHooks({ host: {}, client: { ping: mockSchema } }); function Wrapper({ children }: { children: ReactNode }) { return createElement(hooks.ChannelProvider, { children }); @@ -406,8 +406,8 @@ describe("createChannelHooks", () => { }); test("each factory call creates independent contexts", () => { - const hooks1 = createChannelHooks({ ping: mockSchema }); - const hooks2 = createChannelHooks({ pong: mockSchema }); + const hooks1 = createChannelHooks({ host: { ping: mockSchema }, client: {} }); + const hooks2 = createChannelHooks({ host: { pong: mockSchema }, client: {} }); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); // hooks2.useChannel should throw even when hooks1's provider is present diff --git a/samples/advanced/src/schemas.ts b/samples/advanced/src/schemas.ts index e03e034..4223e96 100644 --- a/samples/advanced/src/schemas.ts +++ b/samples/advanced/src/schemas.ts @@ -2,11 +2,15 @@ import { z } from "zod"; // In a real app, put these in a shared file (e.g. shared/schemas.ts). export const schemas = { - /** Webview -> Bun: counter incremented */ - setCounter: z.number(), - randomize: z.void(), - /** Bun -> Webview: echo back the last message */ - counter: z.number(), + host: { + /** Bun -> Webview: echo back the last message */ + counter: z.number(), + }, + client: { + /** Webview -> Bun: counter incremented */ + setCounter: z.number(), + randomize: z.void(), + }, }; export type IpcSchemas = typeof schemas; diff --git a/samples/basic/src/intercept.ts b/samples/basic/src/intercept.ts index 6d7d652..2018461 100644 --- a/samples/basic/src/intercept.ts +++ b/samples/basic/src/intercept.ts @@ -10,7 +10,7 @@ const ch = createWindow( }, { injectClient: false, - schemas: {}, + schemas: { host: {}, client: {} }, onValidationError: (type, payload) => { console.warn(`[Bun] Invalid "${type}" payload:`, payload); }, diff --git a/samples/basic/src/typed-ipc.ts b/samples/basic/src/typed-ipc.ts index 1e5f976..622d4cd 100644 --- a/samples/basic/src/typed-ipc.ts +++ b/samples/basic/src/typed-ipc.ts @@ -13,14 +13,18 @@ import { createWindow } from "@nativewindow/ipc"; // Define schemas — types are inferred automatically. // In a real app, put these in a shared file (e.g. shared/schemas.ts). const schemas = { - /** Webview -> Bun: user clicked somewhere */ - "user-click": z.object({ x: z.number(), y: z.number() }), - /** Webview -> Bun: counter incremented */ - counter: z.number(), - /** Bun -> Webview: update the displayed title */ - "update-title": z.string(), - /** Bun -> Webview: echo back the last message */ - echo: z.string(), + host: { + /** Bun -> Webview: update the displayed title */ + "update-title": z.string(), + /** Bun -> Webview: echo back the last message */ + echo: z.string(), + }, + client: { + /** Webview -> Bun: user clicked somewhere */ + "user-click": z.object({ x: z.number(), y: z.number() }), + /** Webview -> Bun: counter incremented */ + counter: z.number(), + }, }; // Create a typed channel window (init + event pump start automatically) @@ -153,9 +157,9 @@ ch.on("counter", (n) => { }); // These would be type errors (uncomment to see): -// ch.send("counter", "wrong"); // string is not assignable to number -// ch.send("typo", 123); // "typo" does not exist in schemas -// ch.on("counter", (s: string) => {}); // string is not assignable to number +// ch.send("counter", 42); // "counter" is a client event, not sendable by host +// ch.send("typo", 123); // "typo" does not exist in schemas +// ch.on("update-title", (t) => {}); // "update-title" is a host event, not receivable by host ch.window.onClose(() => { console.log("[Bun] Window closed"); diff --git a/samples/security/src/index.ts b/samples/security/src/index.ts index c5c7164..7efa482 100644 --- a/samples/security/src/index.ts +++ b/samples/security/src/index.ts @@ -30,17 +30,21 @@ const origin = loadHtmlOrigin(); // ── Schemas ──────────────────────────────────────────────── const schemas = { - /** Webview -> Host: user performed an action */ - "user-action": z.object({ - action: z.string().max(50), - timestamp: z.number(), - }), - /** Webview -> Host: request a cookie audit */ - "request-cookies": z.literal(true), - /** Host -> Webview: status/feedback message */ - status: z.string(), - /** Host -> Webview: cookie audit report */ - "cookie-report": z.string(), + host: { + /** Host -> Webview: status/feedback message */ + status: z.string(), + /** Host -> Webview: cookie audit report */ + "cookie-report": z.string(), + }, + client: { + /** Webview -> Host: user performed an action */ + "user-action": z.object({ + action: z.string().max(50), + timestamp: z.number(), + }), + /** Webview -> Host: request a cookie audit */ + "request-cookies": z.literal(true), + }, }; // ── Window + Channel ─────────────────────────────────────── diff --git a/samples/security/src/ipc-hardening.ts b/samples/security/src/ipc-hardening.ts index 987bbf4..32349c3 100644 --- a/samples/security/src/ipc-hardening.ts +++ b/samples/security/src/ipc-hardening.ts @@ -20,15 +20,19 @@ import { z } from "zod"; import { createWindow } from "@nativewindow/ipc"; const schemas = { - /** Webview -> Host: simple ping */ - ping: z.string().max(100), - /** Webview -> Host: structured user data */ - "user-data": z.object({ - name: z.string().min(1).max(50), - age: z.number().int().positive(), - }), - /** Host -> Webview: feedback message */ - feedback: z.string(), + host: { + /** Host -> Webview: feedback message */ + feedback: z.string(), + }, + client: { + /** Webview -> Host: simple ping */ + ping: z.string().max(100), + /** Webview -> Host: structured user data */ + "user-data": z.object({ + name: z.string().min(1).max(50), + age: z.number().int().positive(), + }), + }, }; const ch = createWindow( diff --git a/samples/security/src/trusted-origins.ts b/samples/security/src/trusted-origins.ts index d416269..9f840cf 100644 --- a/samples/security/src/trusted-origins.ts +++ b/samples/security/src/trusted-origins.ts @@ -28,10 +28,14 @@ import { loadHtmlOrigin } from "@nativewindow/webview"; const origin = loadHtmlOrigin(); const schemas = { - /** Webview -> Host: ping request */ - ping: z.string(), - /** Host -> Webview: status response */ - status: z.string(), + host: { + /** Host -> Webview: status response */ + status: z.string(), + }, + client: { + /** Webview -> Host: ping request */ + ping: z.string(), + }, }; const ch = createWindow(