@nativewindow/ipc 0.2.0 → 1.0.2

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.
Files changed (4) hide show
  1. package/README.md +31 -19
  2. package/dist/client.js +148 -121
  3. package/dist/index.js +224 -133
  4. package/package.json +6 -6
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@nativewindow/ipc)](https://www.npmjs.com/package/@nativewindow/ipc)
4
4
 
5
- > [!WARNING]
6
- > This project is in **alpha**. APIs may change without notice.
5
+ > [!NOTE]
6
+ > This project is in **beta**. APIs may change without notice.
7
7
 
8
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
9
 
@@ -25,13 +25,18 @@ const ch = createWindow(
25
25
  { title: "Typed IPC" },
26
26
  {
27
27
  schemas: {
28
- "user-click": z.object({ x: z.number(), y: z.number() }),
29
- "update-title": z.string(),
30
- counter: z.number(),
28
+ host: {
29
+ "update-title": z.string(),
30
+ },
31
+ client: {
32
+ "user-click": z.object({ x: z.number(), y: z.number() }),
33
+ counter: z.number(),
34
+ },
31
35
  },
32
36
  },
33
37
  );
34
38
 
39
+ // Receive typed messages from the webview (client events)
35
40
  ch.on("user-click", (pos) => {
36
41
  // pos: { x: number; y: number }
37
42
  console.log(`Click at ${pos.x}, ${pos.y}`);
@@ -42,8 +47,8 @@ ch.on("counter", (n) => {
42
47
  ch.send("update-title", `Count: ${n}`);
43
48
  });
44
49
 
45
- // ch.send("counter", "wrong"); // Type error!
46
- // ch.send("typo", 123); // Type error!
50
+ // ch.send("counter", "wrong"); // Type error: "counter" is a client event
51
+ // ch.send("typo", 123); // Type error: "typo" does not exist
47
52
 
48
53
  ch.window.loadHtml(`<html>...</html>`);
49
54
  ```
@@ -71,12 +76,19 @@ import { createChannelClient } from "@nativewindow/ipc/client";
71
76
 
72
77
  const ch = createChannelClient({
73
78
  schemas: {
74
- counter: z.number(),
75
- "update-title": z.string(),
79
+ host: {
80
+ "update-title": z.string(),
81
+ },
82
+ client: {
83
+ counter: z.number(),
84
+ },
76
85
  },
77
86
  });
78
87
 
88
+ // Send client events to the host
79
89
  ch.send("counter", 42); // Typed!
90
+
91
+ // Receive host events from the host
80
92
  ch.on("update-title", (t) => {
81
93
  // t: string
82
94
  document.title = t;
@@ -103,16 +115,16 @@ Create a typed channel client inside the webview for use in bundled apps.
103
115
 
104
116
  ### Channel Options
105
117
 
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 |
118
+ | Option | Type | Default | Description |
119
+ | ---------------------- | ---------------------------------------- | ---------- | --------------------------------------------------------------------------------------------- |
120
+ | `schemas` | `{ host: SchemaMap; client: SchemaMap }` | _required_ | Directional schemas — `host` for events the host sends, `client` for events the webview sends |
121
+ | `injectClient` | `boolean` | `true` | Auto-inject client script into webview |
122
+ | `onValidationError` | `function` | — | Called when a payload fails validation |
123
+ | `trustedOrigins` | `string[]` | — | Restrict IPC to specific origins |
124
+ | `maxMessageSize` | `number` | `1048576` | Max message size in bytes |
125
+ | `rateLimit` | `number` | — | Max messages per second |
126
+ | `maxListenersPerEvent` | `number` | — | Max listeners per event type |
127
+ | `channelId` | `string` | — | Unique channel identifier |
116
128
 
117
129
  ### Schema Support
118
130
 
package/dist/client.js CHANGED
@@ -1,129 +1,156 @@
1
- const MAX_MESSAGE_SIZE = 1048576;
1
+ //#region client.ts
2
+ /**
3
+ * Default maximum IPC message size (1 MB).
4
+ * @internal
5
+ */
6
+ var MAX_MESSAGE_SIZE = 1048576;
2
7
  function encode(type, payload) {
3
- return JSON.stringify({ $ch: type, p: payload });
8
+ return JSON.stringify({
9
+ $ch: type,
10
+ p: payload
11
+ });
4
12
  }
5
- const DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
13
+ /** @internal Keys that could pollute prototypes if merged into target objects. */
14
+ var DANGEROUS_KEYS = new Set([
15
+ "__proto__",
16
+ "constructor",
17
+ "prototype"
18
+ ]);
6
19
  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;
20
+ if (raw.length > MAX_MESSAGE_SIZE) return null;
21
+ try {
22
+ const parsed = JSON.parse(raw, (key, value) => DANGEROUS_KEYS.has(key) ? void 0 : value);
23
+ if (typeof parsed === "object" && parsed !== null && "$ch" in parsed && typeof parsed.$ch === "string") return parsed;
24
+ } catch {}
25
+ return null;
19
26
  }
27
+ /**
28
+ * Validate a payload against a schema using `safeParse()`.
29
+ * Returns the parsed data on success so that schema transforms are honored.
30
+ * @internal
31
+ */
20
32
  function validatePayload(schema, data) {
21
- const result = schema.safeParse(data);
22
- return result.success ? { success: true, data: result.data } : { success: false };
33
+ const result = schema.safeParse(data);
34
+ return result.success ? {
35
+ success: true,
36
+ data: result.data
37
+ } : { success: false };
23
38
  }
39
+ /**
40
+ * Create a typed channel client for use inside the webview.
41
+ * Call this once; it hooks into the native IPC bridge.
42
+ *
43
+ * The client sends events defined by the `client` schemas and receives
44
+ * events defined by the `host` schemas — the inverse of the host side.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * import { z } from "zod";
49
+ * import { createChannelClient } from "native-window-ipc/client";
50
+ *
51
+ * const ch = createChannelClient({
52
+ * schemas: {
53
+ * host: { "update-title": z.string() },
54
+ * client: { "user-click": z.object({ x: z.number(), y: z.number() }) },
55
+ * },
56
+ * });
57
+ *
58
+ * ch.send("user-click", { x: 10, y: 20 }); // only client events
59
+ * ch.on("update-title", (title) => { // only host events
60
+ * document.title = title;
61
+ * });
62
+ * ```
63
+ */
24
64
  function createChannelClient(options) {
25
- const { schemas, onValidationError } = options;
26
- const hostSchemas = schemas.host;
27
- schemas.client;
28
- const _push = Array.prototype.push;
29
- const _indexOf = Array.prototype.indexOf;
30
- const _splice = Array.prototype.splice;
31
- const listeners = /* @__PURE__ */ new Map();
32
- const channel = {
33
- send(...args) {
34
- const [type, payload] = args;
35
- window.ipc.postMessage(encode(type, payload));
36
- },
37
- on(type, handler) {
38
- let set = listeners.get(type);
39
- if (!set) {
40
- set = /* @__PURE__ */ new Set();
41
- listeners.set(type, set);
42
- }
43
- set.add(handler);
44
- },
45
- off(type, handler) {
46
- const set = listeners.get(type);
47
- if (set) set.delete(handler);
48
- }
49
- };
50
- const externalListeners = [];
51
- const orig = window.__native_message__;
52
- try {
53
- Object.defineProperty(window, "__native_message__", {
54
- value(msg) {
55
- const env = decode(msg);
56
- if (env) {
57
- if (!(env.$ch in hostSchemas)) {
58
- for (const fn of externalListeners) {
59
- try {
60
- fn(msg);
61
- } catch {
62
- }
63
- }
64
- orig?.(msg);
65
- return;
66
- }
67
- const set = listeners.get(env.$ch);
68
- if (set) {
69
- const schema = hostSchemas[env.$ch];
70
- let validatedPayload = env.p;
71
- if (schema) {
72
- const result = validatePayload(schema, env.p);
73
- if (!result.success) {
74
- onValidationError?.(env.$ch, env.p);
75
- return;
76
- }
77
- validatedPayload = result.data;
78
- }
79
- for (const fn of set) {
80
- try {
81
- fn(validatedPayload);
82
- } catch {
83
- }
84
- }
85
- return;
86
- }
87
- }
88
- for (const fn of externalListeners) {
89
- try {
90
- fn(msg);
91
- } catch {
92
- }
93
- }
94
- orig?.(msg);
95
- },
96
- writable: false,
97
- configurable: false
98
- });
99
- } catch {
100
- }
101
- try {
102
- Object.defineProperty(window, "__native_message_listeners__", {
103
- value: Object.freeze({
104
- add(fn) {
105
- if (typeof fn === "function") _push.call(externalListeners, fn);
106
- },
107
- remove(fn) {
108
- const idx = _indexOf.call(externalListeners, fn);
109
- if (idx !== -1) _splice.call(externalListeners, idx, 1);
110
- }
111
- }),
112
- writable: false,
113
- configurable: false
114
- });
115
- } catch {
116
- }
117
- try {
118
- Object.defineProperty(window, "__channel__", {
119
- value: channel,
120
- writable: false,
121
- configurable: false
122
- });
123
- } catch {
124
- }
125
- return channel;
65
+ const { schemas, onValidationError } = options;
66
+ const hostSchemas = schemas.host;
67
+ schemas.client;
68
+ const _push = Array.prototype.push;
69
+ const _indexOf = Array.prototype.indexOf;
70
+ const _splice = Array.prototype.splice;
71
+ const listeners = /* @__PURE__ */ new Map();
72
+ const channel = {
73
+ send(...args) {
74
+ const [type, payload] = args;
75
+ window.ipc.postMessage(encode(type, payload));
76
+ },
77
+ on(type, handler) {
78
+ let set = listeners.get(type);
79
+ if (!set) {
80
+ set = /* @__PURE__ */ new Set();
81
+ listeners.set(type, set);
82
+ }
83
+ set.add(handler);
84
+ },
85
+ off(type, handler) {
86
+ const set = listeners.get(type);
87
+ if (set) set.delete(handler);
88
+ }
89
+ };
90
+ const externalListeners = [];
91
+ const orig = window.__native_message__;
92
+ try {
93
+ Object.defineProperty(window, "__native_message__", {
94
+ value(msg) {
95
+ const env = decode(msg);
96
+ if (env) {
97
+ if (!(env.$ch in hostSchemas)) {
98
+ for (const fn of externalListeners) try {
99
+ fn(msg);
100
+ } catch {}
101
+ orig?.(msg);
102
+ return;
103
+ }
104
+ const set = listeners.get(env.$ch);
105
+ if (set) {
106
+ const schema = hostSchemas[env.$ch];
107
+ let validatedPayload = env.p;
108
+ if (schema) {
109
+ const result = validatePayload(schema, env.p);
110
+ if (!result.success) {
111
+ onValidationError?.(env.$ch, env.p);
112
+ return;
113
+ }
114
+ validatedPayload = result.data;
115
+ }
116
+ for (const fn of set) try {
117
+ fn(validatedPayload);
118
+ } catch {}
119
+ return;
120
+ }
121
+ }
122
+ for (const fn of externalListeners) try {
123
+ fn(msg);
124
+ } catch {}
125
+ orig?.(msg);
126
+ },
127
+ writable: false,
128
+ configurable: false
129
+ });
130
+ } catch {}
131
+ try {
132
+ Object.defineProperty(window, "__native_message_listeners__", {
133
+ value: Object.freeze({
134
+ add(fn) {
135
+ if (typeof fn === "function") _push.call(externalListeners, fn);
136
+ },
137
+ remove(fn) {
138
+ const idx = _indexOf.call(externalListeners, fn);
139
+ if (idx !== -1) _splice.call(externalListeners, idx, 1);
140
+ }
141
+ }),
142
+ writable: false,
143
+ configurable: false
144
+ });
145
+ } catch {}
146
+ try {
147
+ Object.defineProperty(window, "__channel__", {
148
+ value: channel,
149
+ writable: false,
150
+ configurable: false
151
+ });
152
+ } catch {}
153
+ return channel;
126
154
  }
127
- export {
128
- createChannelClient
129
- };
155
+ //#endregion
156
+ export { createChannelClient };
package/dist/index.js CHANGED
@@ -1,54 +1,107 @@
1
1
  import { NativeWindow } from "@nativewindow/webview";
2
- const MAX_MESSAGE_SIZE = 1048576;
2
+ //#region index.ts
3
+ /**
4
+ * Default maximum IPC message size (1 MB).
5
+ * Messages exceeding this limit are silently dropped to prevent
6
+ * memory exhaustion from oversized payloads.
7
+ * @internal
8
+ */
9
+ var MAX_MESSAGE_SIZE = 1048576;
10
+ /**
11
+ * Validate a payload against a schema using `safeParse()`.
12
+ * Returns the parsed data on success so that schema transforms are honored.
13
+ * @internal
14
+ */
3
15
  function validatePayload(schema, data) {
4
- const result = schema.safeParse(data);
5
- return result.success ? { success: true, data: result.data } : { success: false };
16
+ const result = schema.safeParse(data);
17
+ return result.success ? {
18
+ success: true,
19
+ data: result.data
20
+ } : { success: false };
6
21
  }
7
22
  function isEnvelope(data) {
8
- return typeof data === "object" && data !== null && "$ch" in data && typeof data.$ch === "string";
23
+ return typeof data === "object" && data !== null && "$ch" in data && typeof data.$ch === "string";
9
24
  }
10
25
  function encode(type, payload) {
11
- return JSON.stringify({ $ch: type, p: payload });
26
+ return JSON.stringify({
27
+ $ch: type,
28
+ p: payload
29
+ });
12
30
  }
13
- const DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
31
+ /** @internal Keys that could pollute prototypes if merged into target objects. */
32
+ var DANGEROUS_KEYS = new Set([
33
+ "__proto__",
34
+ "constructor",
35
+ "prototype"
36
+ ]);
14
37
  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
- }
38
+ if (raw.length > maxSize) return null;
39
+ try {
40
+ const parsed = JSON.parse(raw, (key, value) => DANGEROUS_KEYS.has(key) ? void 0 : value);
41
+ return isEnvelope(parsed) ? parsed : null;
42
+ } catch {
43
+ return null;
44
+ }
25
45
  }
46
+ /**
47
+ * Normalize an origin string using the WHATWG URL Standard.
48
+ * Lowercases scheme/host, strips default ports (80/443), and
49
+ * strips userinfo — matching the Rust `url` crate behavior.
50
+ * Returns `null` for malformed URLs or opaque origins.
51
+ * @internal
52
+ */
26
53
  function normalizeOrigin(origin) {
27
- try {
28
- const o = new URL(origin).origin;
29
- return o === "null" ? null : o;
30
- } catch {
31
- return null;
32
- }
54
+ try {
55
+ const o = new URL(origin).origin;
56
+ return o === "null" ? null : o;
57
+ } catch {
58
+ return null;
59
+ }
33
60
  }
61
+ /**
62
+ * Check if a URL's origin matches any of the trusted origins.
63
+ * The `trustedOrigins` array must already be normalized via
64
+ * {@link normalizeOrigin} for correct comparison.
65
+ * @internal
66
+ */
34
67
  function isOriginTrusted(url, trustedOrigins) {
35
- try {
36
- const parsed = new URL(url);
37
- return trustedOrigins.includes(parsed.origin);
38
- } catch {
39
- return false;
40
- }
68
+ try {
69
+ const parsed = new URL(url);
70
+ return trustedOrigins.includes(parsed.origin);
71
+ } catch {
72
+ return false;
73
+ }
41
74
  }
75
+ /**
76
+ * Returns the webview-side channel client as a self-contained JS string.
77
+ * This is the same logic as `client.ts`, minified for injection.
78
+ * Can also be embedded in a `<script>` tag manually.
79
+ *
80
+ * When `options.channelId` is provided, the injected script prefixes all
81
+ * `$ch` values with the channel ID, matching the host-side behavior.
82
+ *
83
+ * **Note:** The injected client does not support payload validation.
84
+ * For client-side validation, use the bundled {@link createChannelClient}
85
+ * import from `native-window-ipc/client` with the `schemas` option.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * // Without namespace (default)
90
+ * const script = getClientScript();
91
+ *
92
+ * // With namespace
93
+ * const script = getClientScript({ channelId: "abc123" });
94
+ * ```
95
+ */
42
96
  function getClientScript(options) {
43
- const prefix = options?.channelId ?? "";
44
- const prefixLiteral = JSON.stringify(prefix);
45
- return `(function(){
97
+ const prefix = options?.channelId ?? "";
98
+ return `(function(){
46
99
  var _slice=Array.prototype.slice;
47
100
  var _filter=Array.prototype.filter;
48
101
  var _push=Array.prototype.push;
49
102
  var _indexOf=Array.prototype.indexOf;
50
103
  var _splice=Array.prototype.splice;
51
- var _pfx=${prefixLiteral};
104
+ var _pfx=${JSON.stringify(prefix)};
52
105
  var _l=Object.create(null);
53
106
  var _el=[];
54
107
  function _e(t,p){return JSON.stringify({$ch:_pfx?_pfx+":"+t:t,p:p})}
@@ -70,108 +123,146 @@ try{Object.defineProperty(window,'__native_message_listeners__',{value:Object.fr
70
123
  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
124
  })();`;
72
125
  }
126
+ /**
127
+ * Wrap an existing NativeWindow with a typed message channel.
128
+ *
129
+ * Schemas are required — they provide both TypeScript types and runtime
130
+ * validation for each event. Compatible with Zod v4, Valibot v1, and
131
+ * any schema library implementing the `safeParse()` interface.
132
+ *
133
+ * The `schemas` option uses directional groups:
134
+ * - `host`: events the host sends to the client.
135
+ * - `client`: events the client sends to the host.
136
+ *
137
+ * On the returned channel, `send()` only accepts `host` event types and
138
+ * `on()`/`off()` only accept `client` event types.
139
+ *
140
+ * @security **Origin restriction:** When `trustedOrigins` is configured,
141
+ * both client script injection and incoming IPC messages are restricted to
142
+ * pages whose URL origin matches the whitelist. The native `onMessage`
143
+ * callback now includes the source page URL, enabling the channel to reject
144
+ * messages from untrusted origins. Empty source URLs (e.g. `about:blank`,
145
+ * `data:` URIs) are treated as untrusted when `trustedOrigins` is set.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * import { z } from "zod";
150
+ * import { createChannel } from "native-window-ipc";
151
+ *
152
+ * const ch = createChannel(win, {
153
+ * schemas: {
154
+ * host: { "update-title": z.string() },
155
+ * client: { "user-click": z.object({ x: z.number(), y: z.number() }) },
156
+ * },
157
+ * });
158
+ * ch.send("update-title", "Hello"); // only host events
159
+ * ch.on("user-click", (p) => {}); // only client events, p: { x: number; y: number }
160
+ * ```
161
+ */
73
162
  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
- schemas.host;
85
- const clientSchemas = schemas.client;
86
- const channelId = channelIdOpt === true ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : channelIdOpt ?? "";
87
- const normalizedOrigins = trustedOrigins?.map(normalizeOrigin).filter((o) => o !== null);
88
- const prefixCh = (type) => channelId ? `${channelId}:${type}` : type;
89
- const unprefixCh = (ch) => {
90
- if (!channelId) return ch;
91
- const pfx = `${channelId}:`;
92
- return ch.startsWith(pfx) ? ch.slice(pfx.length) : null;
93
- };
94
- const listeners = /* @__PURE__ */ new Map();
95
- let _bucketTokens = rateLimit ?? 0;
96
- let _bucketLastRefill = Date.now();
97
- win.onMessage((raw, sourceUrl) => {
98
- if (rateLimit !== void 0 && rateLimit > 0) {
99
- const now = Date.now();
100
- const elapsed = now - _bucketLastRefill;
101
- if (elapsed >= 1e3) {
102
- _bucketTokens = rateLimit;
103
- _bucketLastRefill = now;
104
- }
105
- if (_bucketTokens <= 0) return;
106
- _bucketTokens--;
107
- }
108
- const env = decode(raw, maxMessageSize);
109
- if (!env) return;
110
- const eventType = unprefixCh(env.$ch);
111
- if (eventType === null) return;
112
- if (normalizedOrigins && normalizedOrigins.length > 0) {
113
- if (!isOriginTrusted(sourceUrl, normalizedOrigins)) return;
114
- }
115
- const set = listeners.get(eventType);
116
- if (!set) return;
117
- if (!(eventType in clientSchemas)) return;
118
- const schema = clientSchemas[eventType];
119
- let validatedPayload = env.p;
120
- if (schema) {
121
- const result = validatePayload(schema, env.p);
122
- if (!result.success) {
123
- onValidationError?.(eventType, env.p);
124
- return;
125
- }
126
- validatedPayload = result.data;
127
- }
128
- for (const fn of set) {
129
- try {
130
- fn(validatedPayload);
131
- } catch {
132
- }
133
- }
134
- });
135
- if (injectClient) {
136
- if (!normalizedOrigins || normalizedOrigins.length === 0) {
137
- win.unsafe.evaluateJs(getClientScript({ channelId: channelId || void 0 }));
138
- }
139
- win.onPageLoad((event, url) => {
140
- if (event !== "finished") return;
141
- if (normalizedOrigins && normalizedOrigins.length > 0) {
142
- if (!isOriginTrusted(url, normalizedOrigins)) return;
143
- }
144
- win.unsafe.evaluateJs(getClientScript({ channelId: channelId || void 0 }));
145
- });
146
- }
147
- return {
148
- window: win,
149
- send(...args) {
150
- const [type, payload] = args;
151
- win.postMessage(encode(prefixCh(type), payload));
152
- },
153
- on(type, handler) {
154
- if (!(type in clientSchemas)) return;
155
- let set = listeners.get(type);
156
- if (!set) {
157
- set = /* @__PURE__ */ new Set();
158
- listeners.set(type, set);
159
- }
160
- if (maxListenersPerEvent !== void 0 && set.size >= maxListenersPerEvent) return;
161
- set.add(handler);
162
- },
163
- off(type, handler) {
164
- const set = listeners.get(type);
165
- if (set) set.delete(handler);
166
- }
167
- };
163
+ const { schemas, injectClient = true, onValidationError, trustedOrigins, maxMessageSize, rateLimit, maxListenersPerEvent, channelId: channelIdOpt } = options;
164
+ schemas.host;
165
+ const clientSchemas = schemas.client;
166
+ const channelId = channelIdOpt === true ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : channelIdOpt ?? "";
167
+ const normalizedOrigins = trustedOrigins?.map(normalizeOrigin).filter((o) => o !== null);
168
+ const prefixCh = (type) => channelId ? `${channelId}:${type}` : type;
169
+ const unprefixCh = (ch) => {
170
+ if (!channelId) return ch;
171
+ const pfx = `${channelId}:`;
172
+ return ch.startsWith(pfx) ? ch.slice(pfx.length) : null;
173
+ };
174
+ const listeners = /* @__PURE__ */ new Map();
175
+ let _bucketTokens = rateLimit ?? 0;
176
+ let _bucketLastRefill = Date.now();
177
+ win.onMessage((raw, sourceUrl) => {
178
+ if (rateLimit !== void 0 && rateLimit > 0) {
179
+ const now = Date.now();
180
+ if (now - _bucketLastRefill >= 1e3) {
181
+ _bucketTokens = rateLimit;
182
+ _bucketLastRefill = now;
183
+ }
184
+ if (_bucketTokens <= 0) return;
185
+ _bucketTokens--;
186
+ }
187
+ const env = decode(raw, maxMessageSize);
188
+ if (!env) return;
189
+ const eventType = unprefixCh(env.$ch);
190
+ if (eventType === null) return;
191
+ if (normalizedOrigins && normalizedOrigins.length > 0) {
192
+ if (!isOriginTrusted(sourceUrl, normalizedOrigins)) return;
193
+ }
194
+ const set = listeners.get(eventType);
195
+ if (!set) return;
196
+ if (!(eventType in clientSchemas)) return;
197
+ const schema = clientSchemas[eventType];
198
+ let validatedPayload = env.p;
199
+ if (schema) {
200
+ const result = validatePayload(schema, env.p);
201
+ if (!result.success) {
202
+ onValidationError?.(eventType, env.p);
203
+ return;
204
+ }
205
+ validatedPayload = result.data;
206
+ }
207
+ for (const fn of set) try {
208
+ fn(validatedPayload);
209
+ } catch {}
210
+ });
211
+ if (injectClient) {
212
+ if (!normalizedOrigins || normalizedOrigins.length === 0) win.unsafe.evaluateJs(getClientScript({ channelId: channelId || void 0 }));
213
+ win.onPageLoad((event, url) => {
214
+ if (event !== "finished") return;
215
+ if (normalizedOrigins && normalizedOrigins.length > 0) {
216
+ if (!isOriginTrusted(url, normalizedOrigins)) return;
217
+ }
218
+ win.unsafe.evaluateJs(getClientScript({ channelId: channelId || void 0 }));
219
+ });
220
+ }
221
+ return {
222
+ window: win,
223
+ send(...args) {
224
+ const [type, payload] = args;
225
+ win.postMessage(encode(prefixCh(type), payload));
226
+ },
227
+ on(type, handler) {
228
+ if (!(type in clientSchemas)) return;
229
+ let set = listeners.get(type);
230
+ if (!set) {
231
+ set = /* @__PURE__ */ new Set();
232
+ listeners.set(type, set);
233
+ }
234
+ if (maxListenersPerEvent !== void 0 && set.size >= maxListenersPerEvent) return;
235
+ set.add(handler);
236
+ },
237
+ off(type, handler) {
238
+ const set = listeners.get(type);
239
+ if (set) set.delete(handler);
240
+ }
241
+ };
168
242
  }
243
+ /**
244
+ * Create a new NativeWindow and immediately wrap it with a typed channel.
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * import { z } from "zod";
249
+ * import { createWindow } from "native-window-ipc";
250
+ *
251
+ * const ch = createWindow(
252
+ * { title: "My App" },
253
+ * {
254
+ * schemas: {
255
+ * host: { "update-title": z.string() },
256
+ * client: { "user-click": z.object({ x: z.number(), y: z.number() }) },
257
+ * },
258
+ * },
259
+ * );
260
+ * ch.send("update-title", "Hello"); // only host events
261
+ * ch.on("user-click", (p) => {}); // only client events
262
+ * ```
263
+ */
169
264
  function createWindow(windowOptions, channelOptions) {
170
- const win = new NativeWindow(windowOptions);
171
- return createChannel(win, channelOptions);
265
+ return createChannel(new NativeWindow(windowOptions), channelOptions);
172
266
  }
173
- export {
174
- createChannel,
175
- createWindow,
176
- getClientScript
177
- };
267
+ //#endregion
268
+ export { createChannel, createWindow, getClientScript };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nativewindow/ipc",
3
- "version": "0.2.0",
4
- "description": "Typesafe IPC channels for native-window (alpha)",
3
+ "version": "1.0.2",
4
+ "description": "Typesafe IPC channels for native-window (beta)",
5
5
  "homepage": "https://nativewindow.fcannizzaro.com",
6
6
  "bugs": {
7
7
  "url": "https://github.com/nativewindow/webview/issues"
@@ -46,16 +46,16 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/bun": "^1.3.9",
49
- "arktype": "^2.1.20",
50
- "valibot": "^1.2.0",
51
- "vite": "^7.3.1",
49
+ "arktype": "^2.2.0",
50
+ "valibot": "^1.3.1",
51
+ "vite": "^8.0.3",
52
52
  "vite-plugin-dts": "^4.5.4",
53
53
  "vitest": "^4.0.18",
54
54
  "zod": "^4.3.6"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@nativewindow/webview": "workspace:*",
58
- "typescript": "^5",
58
+ "typescript": "^6.0.2",
59
59
  "zod": "^4.0.0"
60
60
  },
61
61
  "peerDependenciesMeta": {