@nativewindow/ipc 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/client.d.ts +58 -0
- package/dist/client.js +127 -0
- package/dist/index.d.ts +270 -0
- package/dist/index.js +175 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Francesco Saverio Cannizzaro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# @nativewindow/ipc
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@nativewindow/ipc)
|
|
4
|
+
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> This project is in **alpha**. APIs may change without notice.
|
|
7
|
+
|
|
8
|
+
Pure TypeScript typesafe IPC channel layer for [native-window](https://github.com/nativewindow/webview). Schema-based validation with compile-time checked event maps.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
bun add @nativewindow/ipc
|
|
14
|
+
# or
|
|
15
|
+
deno add npm:@nativewindow/ipc
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Host side (Bun/Deno/Node)
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { createWindow } from "@nativewindow/ipc";
|
|
23
|
+
|
|
24
|
+
const ch = createWindow(
|
|
25
|
+
{ title: "Typed IPC" },
|
|
26
|
+
{
|
|
27
|
+
schemas: {
|
|
28
|
+
"user-click": z.object({ x: z.number(), y: z.number() }),
|
|
29
|
+
"update-title": z.string(),
|
|
30
|
+
counter: z.number(),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
ch.on("user-click", (pos) => {
|
|
36
|
+
// pos: { x: number; y: number }
|
|
37
|
+
console.log(`Click at ${pos.x}, ${pos.y}`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
ch.on("counter", (n) => {
|
|
41
|
+
// n: number
|
|
42
|
+
ch.send("update-title", `Count: ${n}`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ch.send("counter", "wrong"); // Type error!
|
|
46
|
+
// ch.send("typo", 123); // Type error!
|
|
47
|
+
|
|
48
|
+
ch.window.loadHtml(`<html>...</html>`);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Webview side (inline HTML)
|
|
52
|
+
|
|
53
|
+
The `__channel__` object is auto-injected by `createWindow` / `createChannel`:
|
|
54
|
+
|
|
55
|
+
```html
|
|
56
|
+
<script>
|
|
57
|
+
__channel__.send("user-click", { x: 10, y: 20 });
|
|
58
|
+
__channel__.on("update-title", (title) => {
|
|
59
|
+
document.title = title;
|
|
60
|
+
});
|
|
61
|
+
</script>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Webview side (bundled app)
|
|
65
|
+
|
|
66
|
+
For webview apps bundled with their own build step, import the client directly:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { z } from "zod";
|
|
70
|
+
import { createChannelClient } from "@nativewindow/ipc/client";
|
|
71
|
+
|
|
72
|
+
const ch = createChannelClient({
|
|
73
|
+
schemas: {
|
|
74
|
+
counter: z.number(),
|
|
75
|
+
"update-title": z.string(),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
ch.send("counter", 42); // Typed!
|
|
80
|
+
ch.on("update-title", (t) => {
|
|
81
|
+
// t: string
|
|
82
|
+
document.title = t;
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
### `createChannel(win, options)`
|
|
89
|
+
|
|
90
|
+
Wrap an existing `NativeWindow` with a typed message channel. Auto-injects the webview client script.
|
|
91
|
+
|
|
92
|
+
### `createWindow(windowOptions, channelOptions)`
|
|
93
|
+
|
|
94
|
+
Convenience: creates a `NativeWindow` and wraps it with `createChannel`.
|
|
95
|
+
|
|
96
|
+
### `getClientScript(options?)`
|
|
97
|
+
|
|
98
|
+
Returns the webview-side client as a self-contained JS string for manual injection.
|
|
99
|
+
|
|
100
|
+
### `createChannelClient(options)` (from `/client`)
|
|
101
|
+
|
|
102
|
+
Create a typed channel client inside the webview for use in bundled apps.
|
|
103
|
+
|
|
104
|
+
### Channel Options
|
|
105
|
+
|
|
106
|
+
| Option | Type | Default | Description |
|
|
107
|
+
| ---------------------- | ----------- | ---------- | -------------------------------------- |
|
|
108
|
+
| `schemas` | `SchemaMap` | _required_ | Schema definitions for each event type |
|
|
109
|
+
| `injectClient` | `boolean` | `true` | Auto-inject client script into webview |
|
|
110
|
+
| `onValidationError` | `function` | — | Called when a payload fails validation |
|
|
111
|
+
| `trustedOrigins` | `string[]` | — | Restrict IPC to specific origins |
|
|
112
|
+
| `maxMessageSize` | `number` | `1048576` | Max message size in bytes |
|
|
113
|
+
| `rateLimit` | `number` | — | Max messages per second |
|
|
114
|
+
| `maxListenersPerEvent` | `number` | — | Max listeners per event type |
|
|
115
|
+
| `channelId` | `string` | — | Unique channel identifier |
|
|
116
|
+
|
|
117
|
+
### Schema Support
|
|
118
|
+
|
|
119
|
+
Any schema library implementing the [Standard Schema](https://github.com/standard-schema/standard-schema) interface is supported, including:
|
|
120
|
+
|
|
121
|
+
- [Zod](https://zod.dev) v4
|
|
122
|
+
- [Valibot](https://valibot.dev) v1
|
|
123
|
+
- [ArkType](https://arktype.io) v2
|
|
124
|
+
|
|
125
|
+
## Documentation
|
|
126
|
+
|
|
127
|
+
Full documentation at [nativewindow.fcannizzaro.com](https://nativewindow.fcannizzaro.com)
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { TypedChannel, ValidationErrorHandler, SchemaMap, InferSchemaMap } from '.';
|
|
2
|
+
declare global {
|
|
3
|
+
interface Window {
|
|
4
|
+
__channel__?: TypedChannel<any>;
|
|
5
|
+
__native_message__?: (msg: string) => void;
|
|
6
|
+
__native_message_listeners__?: {
|
|
7
|
+
add(fn: (msg: string) => void): void;
|
|
8
|
+
remove(fn: (msg: string) => void): void;
|
|
9
|
+
};
|
|
10
|
+
ipc: {
|
|
11
|
+
postMessage(msg: string): void;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Options for {@link createChannelClient}.
|
|
17
|
+
* The `schemas` field is required — it provides both TypeScript types
|
|
18
|
+
* and runtime validation for incoming payloads from the host.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { z } from "zod";
|
|
23
|
+
* const ch = createChannelClient({
|
|
24
|
+
* schemas: { counter: z.number(), title: z.string() },
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export interface ChannelClientOptions<S extends SchemaMap> {
|
|
29
|
+
/** Schemas for incoming events. Provides types and runtime validation. */
|
|
30
|
+
schemas: S;
|
|
31
|
+
/**
|
|
32
|
+
* Called when an incoming payload fails schema validation.
|
|
33
|
+
* If not provided, failed payloads are silently dropped.
|
|
34
|
+
*/
|
|
35
|
+
onValidationError?: ValidationErrorHandler;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Create a typed channel client for use inside the webview.
|
|
39
|
+
* Call this once; it hooks into the native IPC bridge.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { z } from "zod";
|
|
44
|
+
* import { createChannelClient } from "native-window-ipc/client";
|
|
45
|
+
*
|
|
46
|
+
* const ch = createChannelClient({
|
|
47
|
+
* schemas: {
|
|
48
|
+
* "user-click": z.object({ x: z.number(), y: z.number() }),
|
|
49
|
+
* "update-title": z.string(),
|
|
50
|
+
* },
|
|
51
|
+
* });
|
|
52
|
+
*
|
|
53
|
+
* ch.on("update-title", (title) => {
|
|
54
|
+
* document.title = title;
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function createChannelClient<S extends SchemaMap>(options: ChannelClientOptions<S>): TypedChannel<InferSchemaMap<S>>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const MAX_MESSAGE_SIZE = 1048576;
|
|
2
|
+
function encode(type, payload) {
|
|
3
|
+
return JSON.stringify({ $ch: type, p: payload });
|
|
4
|
+
}
|
|
5
|
+
const DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
6
|
+
function decode(raw) {
|
|
7
|
+
if (raw.length > MAX_MESSAGE_SIZE) return null;
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(
|
|
10
|
+
raw,
|
|
11
|
+
(key, value) => DANGEROUS_KEYS.has(key) ? void 0 : value
|
|
12
|
+
);
|
|
13
|
+
if (typeof parsed === "object" && parsed !== null && "$ch" in parsed && typeof parsed.$ch === "string") {
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function validatePayload(schema, data) {
|
|
21
|
+
const result = schema.safeParse(data);
|
|
22
|
+
return result.success ? { success: true, data: result.data } : { success: false };
|
|
23
|
+
}
|
|
24
|
+
function createChannelClient(options) {
|
|
25
|
+
const { schemas, onValidationError } = options;
|
|
26
|
+
const _push = Array.prototype.push;
|
|
27
|
+
const _indexOf = Array.prototype.indexOf;
|
|
28
|
+
const _splice = Array.prototype.splice;
|
|
29
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
30
|
+
const channel = {
|
|
31
|
+
send(...args) {
|
|
32
|
+
const [type, payload] = args;
|
|
33
|
+
window.ipc.postMessage(encode(type, payload));
|
|
34
|
+
},
|
|
35
|
+
on(type, handler) {
|
|
36
|
+
let set = listeners.get(type);
|
|
37
|
+
if (!set) {
|
|
38
|
+
set = /* @__PURE__ */ new Set();
|
|
39
|
+
listeners.set(type, set);
|
|
40
|
+
}
|
|
41
|
+
set.add(handler);
|
|
42
|
+
},
|
|
43
|
+
off(type, handler) {
|
|
44
|
+
const set = listeners.get(type);
|
|
45
|
+
if (set) set.delete(handler);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const externalListeners = [];
|
|
49
|
+
const orig = window.__native_message__;
|
|
50
|
+
try {
|
|
51
|
+
Object.defineProperty(window, "__native_message__", {
|
|
52
|
+
value(msg) {
|
|
53
|
+
const env = decode(msg);
|
|
54
|
+
if (env) {
|
|
55
|
+
if (!(env.$ch in schemas)) {
|
|
56
|
+
for (const fn of externalListeners) {
|
|
57
|
+
try {
|
|
58
|
+
fn(msg);
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
orig?.(msg);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const set = listeners.get(env.$ch);
|
|
66
|
+
if (set) {
|
|
67
|
+
const schema = schemas[env.$ch];
|
|
68
|
+
let validatedPayload = env.p;
|
|
69
|
+
if (schema) {
|
|
70
|
+
const result = validatePayload(schema, env.p);
|
|
71
|
+
if (!result.success) {
|
|
72
|
+
onValidationError?.(env.$ch, env.p);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
validatedPayload = result.data;
|
|
76
|
+
}
|
|
77
|
+
for (const fn of set) {
|
|
78
|
+
try {
|
|
79
|
+
fn(validatedPayload);
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const fn of externalListeners) {
|
|
87
|
+
try {
|
|
88
|
+
fn(msg);
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
orig?.(msg);
|
|
93
|
+
},
|
|
94
|
+
writable: false,
|
|
95
|
+
configurable: false
|
|
96
|
+
});
|
|
97
|
+
} catch {
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
Object.defineProperty(window, "__native_message_listeners__", {
|
|
101
|
+
value: Object.freeze({
|
|
102
|
+
add(fn) {
|
|
103
|
+
if (typeof fn === "function") _push.call(externalListeners, fn);
|
|
104
|
+
},
|
|
105
|
+
remove(fn) {
|
|
106
|
+
const idx = _indexOf.call(externalListeners, fn);
|
|
107
|
+
if (idx !== -1) _splice.call(externalListeners, idx, 1);
|
|
108
|
+
}
|
|
109
|
+
}),
|
|
110
|
+
writable: false,
|
|
111
|
+
configurable: false
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
Object.defineProperty(window, "__channel__", {
|
|
117
|
+
value: channel,
|
|
118
|
+
writable: false,
|
|
119
|
+
configurable: false
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
return channel;
|
|
124
|
+
}
|
|
125
|
+
export {
|
|
126
|
+
createChannelClient
|
|
127
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { NativeWindow, WindowOptions } from '@nativewindow/webview';
|
|
2
|
+
/** User-defined map of event name -> payload type. */
|
|
3
|
+
export type EventMap = Record<string, unknown>;
|
|
4
|
+
/**
|
|
5
|
+
* A schema that can validate data at runtime via `safeParse()`.
|
|
6
|
+
* Compatible with Zod v4, Valibot v1, and any library exposing
|
|
7
|
+
* a `safeParse` method returning `{ success: boolean; data?: T }`.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { z } from "zod";
|
|
12
|
+
* const schema: SchemaLike = z.string(); // compatible
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export interface SchemaLike {
|
|
16
|
+
safeParse(data: unknown): {
|
|
17
|
+
success: true;
|
|
18
|
+
data: unknown;
|
|
19
|
+
} | {
|
|
20
|
+
success: false;
|
|
21
|
+
error?: unknown;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Map of event names to schemas.
|
|
26
|
+
* TypeScript types are derived from the schemas via {@link InferSchemaMap}.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* import { z } from "zod";
|
|
31
|
+
* const schemas = {
|
|
32
|
+
* ping: z.string(),
|
|
33
|
+
* pong: z.number(),
|
|
34
|
+
* data: z.object({ x: z.number(), y: z.number() }),
|
|
35
|
+
* } satisfies SchemaMap;
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export type SchemaMap = Record<string, SchemaLike>;
|
|
39
|
+
/**
|
|
40
|
+
* Infer the output type from a single schema.
|
|
41
|
+
* Supports Zod v4 (`_zod.output`), Valibot v1 (`_types.output`),
|
|
42
|
+
* and the Standard Schema spec (`~standard.types.output`).
|
|
43
|
+
* Falls back to `unknown` for unrecognized schema shapes.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { z } from "zod";
|
|
48
|
+
* type S = InferOutput<typeof z.string()>; // string
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export type InferOutput<S> = S extends {
|
|
52
|
+
_zod: {
|
|
53
|
+
output: infer T;
|
|
54
|
+
};
|
|
55
|
+
} ? T : S extends {
|
|
56
|
+
_types?: {
|
|
57
|
+
output: infer T;
|
|
58
|
+
};
|
|
59
|
+
} ? T : S extends {
|
|
60
|
+
"~standard": {
|
|
61
|
+
types?: {
|
|
62
|
+
output: infer T;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
} ? T : unknown;
|
|
66
|
+
/**
|
|
67
|
+
* Derive an {@link EventMap} from a {@link SchemaMap}.
|
|
68
|
+
* Each key maps to the inferred output type of its schema.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* const schemas = { ping: z.string(), pong: z.number() };
|
|
73
|
+
* type Events = InferSchemaMap<typeof schemas>;
|
|
74
|
+
* // ^? { ping: string; pong: number }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export type InferSchemaMap<S extends SchemaMap> = {
|
|
78
|
+
[K in keyof S & string]: InferOutput<S[K]>;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Called when a payload fails runtime validation.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const onError: ValidationErrorHandler = (type, payload) => {
|
|
86
|
+
* console.warn(`Invalid payload for "${type}":`, payload);
|
|
87
|
+
* };
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export type ValidationErrorHandler = (type: string, payload: unknown) => void;
|
|
91
|
+
/**
|
|
92
|
+
* Argument tuple for {@link TypedChannel.send}.
|
|
93
|
+
* When the payload type is `void` or `never`, the payload argument is
|
|
94
|
+
* optional — callers can write `send("ping")` instead of `send("ping", undefined)`.
|
|
95
|
+
*
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
export type SendArgs<T extends EventMap, K extends keyof T & string> = [T[K]] extends [void | never] ? [type: K] | [type: K, payload: T[K]] : [type: K, payload: T[K]];
|
|
99
|
+
/** Typed channel interface (shared shape for both host and webview sides). */
|
|
100
|
+
export interface TypedChannel<T extends EventMap> {
|
|
101
|
+
/** Send a typed message. */
|
|
102
|
+
send<K extends keyof T & string>(...args: SendArgs<T, K>): void;
|
|
103
|
+
/** Register a handler for a typed message. */
|
|
104
|
+
on<K extends keyof T & string>(type: K, handler: (payload: T[K]) => void): void;
|
|
105
|
+
/** Remove a handler for a typed message. */
|
|
106
|
+
off<K extends keyof T & string>(type: K, handler: (payload: T[K]) => void): void;
|
|
107
|
+
}
|
|
108
|
+
/** Host-side channel wrapping a NativeWindow. */
|
|
109
|
+
export interface NativeWindowChannel<T extends EventMap> extends TypedChannel<T> {
|
|
110
|
+
/** The underlying NativeWindow instance. */
|
|
111
|
+
readonly window: NativeWindow;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Options for {@link createChannel}.
|
|
115
|
+
* The `schemas` field is required — it provides both TypeScript types
|
|
116
|
+
* and runtime validation for each event.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* import { z } from "zod";
|
|
121
|
+
* const ch = createChannel(win, {
|
|
122
|
+
* schemas: { ping: z.string(), pong: z.number() },
|
|
123
|
+
* });
|
|
124
|
+
* ch.send("ping", "hello"); // typed from schema
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export interface ChannelOptions<S extends SchemaMap> {
|
|
128
|
+
/** Schemas for each event. Provides both TypeScript types and runtime validation. */
|
|
129
|
+
schemas: S;
|
|
130
|
+
/** Inject the client script into the webview automatically. Default: true */
|
|
131
|
+
injectClient?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Called when an incoming payload fails schema validation.
|
|
134
|
+
* If not provided, failed payloads are silently dropped.
|
|
135
|
+
*/
|
|
136
|
+
onValidationError?: ValidationErrorHandler;
|
|
137
|
+
/**
|
|
138
|
+
* Restrict client script injection to pages from these origins.
|
|
139
|
+
* Each entry should be an origin string (e.g. `"https://example.com"`).
|
|
140
|
+
* When set and `injectClient` is true, the client script is only
|
|
141
|
+
* re-injected on page load if the page URL's origin matches.
|
|
142
|
+
* When set, the initial injection is deferred until the first
|
|
143
|
+
* trusted page load to prevent exposing the IPC bridge to
|
|
144
|
+
* untrusted origins.
|
|
145
|
+
*
|
|
146
|
+
* Origins should not include a trailing slash. The `about:blank` and
|
|
147
|
+
* `data:` URLs have an origin of `"null"` — they will not match unless
|
|
148
|
+
* `"null"` is explicitly listed.
|
|
149
|
+
*
|
|
150
|
+
* @security Use this to prevent the IPC bridge from being available on
|
|
151
|
+
* untrusted pages after navigation.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```ts
|
|
155
|
+
* createChannel(win, {
|
|
156
|
+
* schemas: { ping: z.string() },
|
|
157
|
+
* trustedOrigins: ["https://myapp.com", "https://cdn.myapp.com"],
|
|
158
|
+
* });
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
trustedOrigins?: string[];
|
|
162
|
+
/**
|
|
163
|
+
* Maximum allowed size (in characters) for incoming IPC messages.
|
|
164
|
+
* Messages exceeding this limit are silently dropped.
|
|
165
|
+
* Default: 1 MB (1_048_576 characters).
|
|
166
|
+
*
|
|
167
|
+
* @security Prevents memory exhaustion from oversized payloads.
|
|
168
|
+
*/
|
|
169
|
+
maxMessageSize?: number;
|
|
170
|
+
/**
|
|
171
|
+
* Maximum number of incoming messages allowed per second.
|
|
172
|
+
* When the limit is exceeded, additional messages are silently dropped
|
|
173
|
+
* until the window slides forward. Default: unlimited.
|
|
174
|
+
*
|
|
175
|
+
* @security Prevents flooding from malicious webview content.
|
|
176
|
+
*/
|
|
177
|
+
rateLimit?: number;
|
|
178
|
+
/**
|
|
179
|
+
* Maximum number of listeners allowed per event type.
|
|
180
|
+
* Calls to `on()` that would exceed this limit are silently ignored.
|
|
181
|
+
* Default: unlimited.
|
|
182
|
+
*
|
|
183
|
+
* @security Prevents unbounded memory growth from listener leaks.
|
|
184
|
+
*/
|
|
185
|
+
maxListenersPerEvent?: number;
|
|
186
|
+
/**
|
|
187
|
+
* Channel namespace identifier. When set, all `$ch` values in the IPC
|
|
188
|
+
* envelope are prefixed with `channelId:`, preventing malicious scripts
|
|
189
|
+
* from sending messages that match known event types.
|
|
190
|
+
*
|
|
191
|
+
* - Pass a string to use a fixed namespace.
|
|
192
|
+
* - Pass `true` to auto-generate a random 8-character nonce.
|
|
193
|
+
* - Leave undefined to use the original (unprefixed) envelope format.
|
|
194
|
+
*
|
|
195
|
+
* The injected client script (via `getClientScript`) will include the
|
|
196
|
+
* same prefix, so host and client stay in sync automatically.
|
|
197
|
+
*
|
|
198
|
+
* @security Prevents channel name collision / namespace squatting
|
|
199
|
+
* from untrusted scripts in the webview.
|
|
200
|
+
*/
|
|
201
|
+
channelId?: string | true;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Returns the webview-side channel client as a self-contained JS string.
|
|
205
|
+
* This is the same logic as `client.ts`, minified for injection.
|
|
206
|
+
* Can also be embedded in a `<script>` tag manually.
|
|
207
|
+
*
|
|
208
|
+
* When `options.channelId` is provided, the injected script prefixes all
|
|
209
|
+
* `$ch` values with the channel ID, matching the host-side behavior.
|
|
210
|
+
*
|
|
211
|
+
* **Note:** The injected client does not support payload validation.
|
|
212
|
+
* For client-side validation, use the bundled {@link createChannelClient}
|
|
213
|
+
* import from `native-window-ipc/client` with the `schemas` option.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```ts
|
|
217
|
+
* // Without namespace (default)
|
|
218
|
+
* const script = getClientScript();
|
|
219
|
+
*
|
|
220
|
+
* // With namespace
|
|
221
|
+
* const script = getClientScript({ channelId: "abc123" });
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
export declare function getClientScript(options?: {
|
|
225
|
+
channelId?: string;
|
|
226
|
+
}): string;
|
|
227
|
+
/**
|
|
228
|
+
* Wrap an existing NativeWindow with a typed message channel.
|
|
229
|
+
*
|
|
230
|
+
* Schemas are required — they provide both TypeScript types and runtime
|
|
231
|
+
* validation for each event. Compatible with Zod v4, Valibot v1, and
|
|
232
|
+
* any schema library implementing the `safeParse()` interface.
|
|
233
|
+
*
|
|
234
|
+
* @security **Origin restriction:** When `trustedOrigins` is configured,
|
|
235
|
+
* both client script injection and incoming IPC messages are restricted to
|
|
236
|
+
* pages whose URL origin matches the whitelist. The native `onMessage`
|
|
237
|
+
* callback now includes the source page URL, enabling the channel to reject
|
|
238
|
+
* messages from untrusted origins. Empty source URLs (e.g. `about:blank`,
|
|
239
|
+
* `data:` URIs) are treated as untrusted when `trustedOrigins` is set.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* import { z } from "zod";
|
|
244
|
+
* import { createChannel } from "native-window-ipc";
|
|
245
|
+
*
|
|
246
|
+
* const ch = createChannel(win, {
|
|
247
|
+
* schemas: { ping: z.string(), pong: z.number() },
|
|
248
|
+
* });
|
|
249
|
+
* ch.send("ping", "hello"); // typed from schema
|
|
250
|
+
* ch.on("pong", (n) => {}); // n: number
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
export declare function createChannel<S extends SchemaMap>(win: NativeWindow, options: ChannelOptions<S>): NativeWindowChannel<InferSchemaMap<S>>;
|
|
254
|
+
/**
|
|
255
|
+
* Create a new NativeWindow and immediately wrap it with a typed channel.
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```ts
|
|
259
|
+
* import { z } from "zod";
|
|
260
|
+
* import { createWindow } from "native-window-ipc";
|
|
261
|
+
*
|
|
262
|
+
* const ch = createWindow(
|
|
263
|
+
* { title: "My App" },
|
|
264
|
+
* { schemas: { counter: z.number(), title: z.string() } },
|
|
265
|
+
* );
|
|
266
|
+
* ch.send("counter", 42); // typed from schema
|
|
267
|
+
* ch.window.loadHtml("<html>...</html>");
|
|
268
|
+
* ```
|
|
269
|
+
*/
|
|
270
|
+
export declare function createWindow<S extends SchemaMap>(windowOptions: WindowOptions | undefined, channelOptions: ChannelOptions<S>): NativeWindowChannel<InferSchemaMap<S>>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { NativeWindow } from "@nativewindow/webview";
|
|
2
|
+
const MAX_MESSAGE_SIZE = 1048576;
|
|
3
|
+
function validatePayload(schema, data) {
|
|
4
|
+
const result = schema.safeParse(data);
|
|
5
|
+
return result.success ? { success: true, data: result.data } : { success: false };
|
|
6
|
+
}
|
|
7
|
+
function isEnvelope(data) {
|
|
8
|
+
return typeof data === "object" && data !== null && "$ch" in data && typeof data.$ch === "string";
|
|
9
|
+
}
|
|
10
|
+
function encode(type, payload) {
|
|
11
|
+
return JSON.stringify({ $ch: type, p: payload });
|
|
12
|
+
}
|
|
13
|
+
const DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
14
|
+
function decode(raw, maxSize = MAX_MESSAGE_SIZE) {
|
|
15
|
+
if (raw.length > maxSize) return null;
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(
|
|
18
|
+
raw,
|
|
19
|
+
(key, value) => DANGEROUS_KEYS.has(key) ? void 0 : value
|
|
20
|
+
);
|
|
21
|
+
return isEnvelope(parsed) ? parsed : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function normalizeOrigin(origin) {
|
|
27
|
+
try {
|
|
28
|
+
const o = new URL(origin).origin;
|
|
29
|
+
return o === "null" ? null : o;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function isOriginTrusted(url, trustedOrigins) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = new URL(url);
|
|
37
|
+
return trustedOrigins.includes(parsed.origin);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function getClientScript(options) {
|
|
43
|
+
const prefix = options?.channelId ?? "";
|
|
44
|
+
const prefixLiteral = JSON.stringify(prefix);
|
|
45
|
+
return `(function(){
|
|
46
|
+
var _slice=Array.prototype.slice;
|
|
47
|
+
var _filter=Array.prototype.filter;
|
|
48
|
+
var _push=Array.prototype.push;
|
|
49
|
+
var _indexOf=Array.prototype.indexOf;
|
|
50
|
+
var _splice=Array.prototype.splice;
|
|
51
|
+
var _pfx=${prefixLiteral};
|
|
52
|
+
var _l=Object.create(null);
|
|
53
|
+
var _el=[];
|
|
54
|
+
function _e(t,p){return JSON.stringify({$ch:_pfx?_pfx+":"+t:t,p:p})}
|
|
55
|
+
function _d(r){if(r.length>1048576)return null;var _dk={__proto__:1,constructor:1,prototype:1};try{var o=JSON.parse(r,function(k,v){return _dk[k]?void 0:v});if(o&&typeof o.$ch==="string")return o}catch(e){}return null}
|
|
56
|
+
function _uch(ch){if(!_pfx)return ch;if(ch.indexOf(_pfx+":")===0)return ch.slice(_pfx.length+1);return null}
|
|
57
|
+
var ch={
|
|
58
|
+
send:function(t,p){window.ipc.postMessage(_e(t,p))},
|
|
59
|
+
on:function(t,h){if(!_l[t])_l[t]=[];_push.call(_l[t],h)},
|
|
60
|
+
off:function(t,h){if(!_l[t])return;_l[t]=_filter.call(_l[t],function(f){return f!==h})}
|
|
61
|
+
};
|
|
62
|
+
var _orig=window.__native_message__;
|
|
63
|
+
try{Object.defineProperty(window,'__native_message__',{value:function(msg){
|
|
64
|
+
var env=_d(msg);
|
|
65
|
+
if(env){var key=_uch(env.$ch);if(key!==null&&_l[key]){var fns=_slice.call(_l[key]);for(var i=0;i<fns.length;i++){try{fns[i](env.p)}catch(e){}}}
|
|
66
|
+
else{for(var j=0;j<_el.length;j++){try{_el[j](msg)}catch(e){}}if(_orig){_orig(msg)}}}
|
|
67
|
+
else{for(var j=0;j<_el.length;j++){try{_el[j](msg)}catch(e){}}if(_orig){_orig(msg)}}
|
|
68
|
+
},writable:false,configurable:false})}catch(e){console.error('[native-window] Failed to define __native_message__:',e)}
|
|
69
|
+
try{Object.defineProperty(window,'__native_message_listeners__',{value:Object.freeze({add:function(fn){if(typeof fn==='function')_push.call(_el,fn)},remove:function(fn){var i=_indexOf.call(_el,fn);if(i!==-1)_splice.call(_el,i,1)}}),writable:false,configurable:false})}catch(e){console.error('[native-window] Failed to define __native_message_listeners__:',e)}
|
|
70
|
+
try{Object.defineProperty(window,'__channel__',{value:Object.freeze(ch),writable:false,configurable:false})}catch(e){console.error('[native-window] Failed to define __channel__:',e)}
|
|
71
|
+
})();`;
|
|
72
|
+
}
|
|
73
|
+
function createChannel(win, options) {
|
|
74
|
+
const {
|
|
75
|
+
schemas,
|
|
76
|
+
injectClient = true,
|
|
77
|
+
onValidationError,
|
|
78
|
+
trustedOrigins,
|
|
79
|
+
maxMessageSize,
|
|
80
|
+
rateLimit,
|
|
81
|
+
maxListenersPerEvent,
|
|
82
|
+
channelId: channelIdOpt
|
|
83
|
+
} = options;
|
|
84
|
+
const channelId = channelIdOpt === true ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : channelIdOpt ?? "";
|
|
85
|
+
const normalizedOrigins = trustedOrigins?.map(normalizeOrigin).filter((o) => o !== null);
|
|
86
|
+
const prefixCh = (type) => channelId ? `${channelId}:${type}` : type;
|
|
87
|
+
const unprefixCh = (ch) => {
|
|
88
|
+
if (!channelId) return ch;
|
|
89
|
+
const pfx = `${channelId}:`;
|
|
90
|
+
return ch.startsWith(pfx) ? ch.slice(pfx.length) : null;
|
|
91
|
+
};
|
|
92
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
93
|
+
let _bucketTokens = rateLimit ?? 0;
|
|
94
|
+
let _bucketLastRefill = Date.now();
|
|
95
|
+
win.onMessage((raw, sourceUrl) => {
|
|
96
|
+
if (rateLimit !== void 0 && rateLimit > 0) {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const elapsed = now - _bucketLastRefill;
|
|
99
|
+
if (elapsed >= 1e3) {
|
|
100
|
+
_bucketTokens = rateLimit;
|
|
101
|
+
_bucketLastRefill = now;
|
|
102
|
+
}
|
|
103
|
+
if (_bucketTokens <= 0) return;
|
|
104
|
+
_bucketTokens--;
|
|
105
|
+
}
|
|
106
|
+
const env = decode(raw, maxMessageSize);
|
|
107
|
+
if (!env) return;
|
|
108
|
+
const eventType = unprefixCh(env.$ch);
|
|
109
|
+
if (eventType === null) return;
|
|
110
|
+
if (normalizedOrigins && normalizedOrigins.length > 0) {
|
|
111
|
+
if (!isOriginTrusted(sourceUrl, normalizedOrigins)) return;
|
|
112
|
+
}
|
|
113
|
+
const set = listeners.get(eventType);
|
|
114
|
+
if (!set) return;
|
|
115
|
+
if (!(eventType in schemas)) return;
|
|
116
|
+
const schema = schemas[eventType];
|
|
117
|
+
let validatedPayload = env.p;
|
|
118
|
+
if (schema) {
|
|
119
|
+
const result = validatePayload(schema, env.p);
|
|
120
|
+
if (!result.success) {
|
|
121
|
+
onValidationError?.(eventType, env.p);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
validatedPayload = result.data;
|
|
125
|
+
}
|
|
126
|
+
for (const fn of set) {
|
|
127
|
+
try {
|
|
128
|
+
fn(validatedPayload);
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
if (injectClient) {
|
|
134
|
+
if (!normalizedOrigins || normalizedOrigins.length === 0) {
|
|
135
|
+
win.unsafe.evaluateJs(getClientScript({ channelId: channelId || void 0 }));
|
|
136
|
+
}
|
|
137
|
+
win.onPageLoad((event, url) => {
|
|
138
|
+
if (event !== "finished") return;
|
|
139
|
+
if (normalizedOrigins && normalizedOrigins.length > 0) {
|
|
140
|
+
if (!isOriginTrusted(url, normalizedOrigins)) return;
|
|
141
|
+
}
|
|
142
|
+
win.unsafe.evaluateJs(getClientScript({ channelId: channelId || void 0 }));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
window: win,
|
|
147
|
+
send(...args) {
|
|
148
|
+
const [type, payload] = args;
|
|
149
|
+
win.postMessage(encode(prefixCh(type), payload));
|
|
150
|
+
},
|
|
151
|
+
on(type, handler) {
|
|
152
|
+
if (!(type in schemas)) return;
|
|
153
|
+
let set = listeners.get(type);
|
|
154
|
+
if (!set) {
|
|
155
|
+
set = /* @__PURE__ */ new Set();
|
|
156
|
+
listeners.set(type, set);
|
|
157
|
+
}
|
|
158
|
+
if (maxListenersPerEvent !== void 0 && set.size >= maxListenersPerEvent) return;
|
|
159
|
+
set.add(handler);
|
|
160
|
+
},
|
|
161
|
+
off(type, handler) {
|
|
162
|
+
const set = listeners.get(type);
|
|
163
|
+
if (set) set.delete(handler);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function createWindow(windowOptions, channelOptions) {
|
|
168
|
+
const win = new NativeWindow(windowOptions);
|
|
169
|
+
return createChannel(win, channelOptions);
|
|
170
|
+
}
|
|
171
|
+
export {
|
|
172
|
+
createChannel,
|
|
173
|
+
createWindow,
|
|
174
|
+
getClientScript
|
|
175
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nativewindow/ipc",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Typesafe IPC channels for native-window (alpha)",
|
|
5
|
+
"homepage": "https://nativewindow.fcannizzaro.com",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/nativewindow/webview/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Francesco Saverio Cannizzaro (fcannizzaro)",
|
|
12
|
+
"url": "https://fcannizzaro.com"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/nativewindow/webview/tree/main/packages/ipc"
|
|
17
|
+
},
|
|
18
|
+
"funding": [
|
|
19
|
+
{
|
|
20
|
+
"type": "patreon",
|
|
21
|
+
"url": "https://www.patreon.com/fcannizzaro"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./client": {
|
|
38
|
+
"types": "./dist/client.d.ts",
|
|
39
|
+
"import": "./dist/client.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"build": "vite build",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "^1.3.9",
|
|
49
|
+
"arktype": "^2.1.20",
|
|
50
|
+
"valibot": "^1.2.0",
|
|
51
|
+
"vite": "^7.3.1",
|
|
52
|
+
"vite-plugin-dts": "^4.5.4",
|
|
53
|
+
"vitest": "^4.0.18",
|
|
54
|
+
"zod": "^4.3.6"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@nativewindow/webview": "workspace:*",
|
|
58
|
+
"typescript": "^5",
|
|
59
|
+
"zod": "^4.0.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"zod": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|