rpc-iframe 0.2.0

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 ADDED
@@ -0,0 +1,38 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AdriAir
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.
22
+
23
+ ADDITIONAL DISCLAIMER:
24
+
25
+ This software facilitates communication between web applications and iframes
26
+ using the browser's postMessage API. Users of this software are solely
27
+ responsible for:
28
+
29
+ 1. Validating and sanitizing all data exchanged between frames
30
+ 2. Properly configuring origin restrictions to prevent unauthorized access
31
+ 3. Implementing additional security measures appropriate to their use case
32
+ 4. Understanding and mitigating security risks inherent in cross-origin
33
+ communication
34
+
35
+ The authors make no guarantees regarding the security, reliability, or
36
+ fitness for any particular purpose of this software. This is experimental
37
+ software and should not be used in production environments without thorough
38
+ security review and testing.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # RPC iFrame
2
+
3
+ ![npm](https://img.shields.io/npm/v/rpc-iframe)
4
+
5
+ Type-safe RPC between iframes. Call methods across frames like they're local functions.
6
+
7
+ ```typescript
8
+ // Parent
9
+ const { remote } = await connectIframe<ChildAPI>(iframe, {
10
+ targetOrigin: "https://child.example.com",
11
+ });
12
+
13
+ const result = await remote.add(2, 3); // 5 — typed, async, done.
14
+ ```
15
+
16
+ ```typescript
17
+ // Child
18
+ expose(
19
+ {
20
+ async add(a: number, b: number) {
21
+ return a + b;
22
+ },
23
+ },
24
+ { allowedOrigin: "https://parent.example.com" },
25
+ );
26
+ ```
27
+
28
+ That's it. No glue code, no message parsing, no `postMessage` boilerplate.
29
+
30
+ Works in both TypeScript and vanilla JavaScript. Types are optional.
31
+
32
+ > **v0.2.0 — Experimental.** API may change before v1.0. Not recommended for production without thorough testing.
33
+
34
+ ## Use cases
35
+
36
+ - Micro-frontends communicating across domains
37
+ - Embedded widgets (payments, auth, analytics)
38
+ - Secure sandboxed apps
39
+
40
+ ## Features
41
+
42
+ - Typed RPC over `postMessage`
43
+ - Promise-based async calls
44
+ - Automatic handshake + origin validation
45
+ - Functional and OOP APIs
46
+ - Works cross-origin, no server, no bundler required
47
+ - ESM + CJS builds
48
+
49
+ ## How it works
50
+
51
+ The child exposes an API.
52
+ The parent connects via a secure handshake.
53
+ All method calls are proxied over `postMessage` as typed async RPC.
54
+
55
+ > If using the `sandbox` attribute on your iframe, make sure to allow `allow-scripts` and `allow-same-origin`.
56
+
57
+ ## Why RPC-iFrame
58
+
59
+ If you work with iframes, you know the pain: raw `postMessage`, manual serialization, no types, origin checks scattered everywhere, zero error context when something breaks.
60
+
61
+ RPC-iFrame replaces all of that with a single abstraction: **typed RPC**. You define an interface, expose it from the child, and call it from the parent. The runtime handles handshakes, security, and cleanup.
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ npm install rpc-iframe
67
+ ```
68
+
69
+ ## What's next
70
+
71
+ Future versions will add:
72
+
73
+ - Contract validation (fail-fast if the child doesn't expose what the parent expects)
74
+ - API discovery (the child advertises its schema during handshake)
75
+ - Dev diagnostics (structured logs for every connection and call)
76
+ - Micro-frontend lifecycle helpers (lazy loading, reconnection, health checks)
77
+
78
+ Check the full [Roadmap](docs/roadmap.md) for details.
79
+
80
+ ## Docs
81
+
82
+ | Document | What you'll find |
83
+ | ------------------------------------------ | --------------------------------------------------------- |
84
+ | [Getting Started](docs/getting-started.md) | Installation, quick start, functional & OOP API usage |
85
+ | [API Reference](docs/api-reference.md) | Full API surface, configuration options, TypeScript types |
86
+ | [Architecture](docs/architecture.md) | Internal design, protocol, transport, RPC flow diagram |
87
+ | [Security](docs/security.md) | Origin validation, method exposure, nonce handshake |
88
+ | [Examples](docs/examples.md) | Payments, micro-frontends, sandboxing, cross-domain data |
89
+ | [Compatibility](docs/compatibility.md) | Browser support table, module formats (ESM/CJS) |
90
+ | [Roadmap](docs/roadmap.md) | v0.1 → v1.0 — every milestone and what it unlocks |
91
+
92
+ ## Contributing
93
+
94
+ Contributions are welcome. Open a PR or file an issue.
95
+
96
+ **Repository:** [github.com/AdriAir/iFrameConnector](https://github.com/AdriAir/iFrameConnector)
97
+ **Issues:** [github.com/AdriAir/iFrameConnector/issues](https://github.com/AdriAir/iFrameConnector/issues)
98
+
99
+ ## License
100
+
101
+ MIT — Copyright (c) 2026 AdriAir. See [LICENSE](LICENSE).
package/dist/index.cjs ADDED
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ IframeConnection: () => IframeConnection,
24
+ IframeExposed: () => IframeExposed,
25
+ connectIframe: () => connectIframe,
26
+ expose: () => expose
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/core/protocol.ts
31
+ var IFC_BRAND = "__ifc__";
32
+ function isProtocolMessage(data) {
33
+ return typeof data === "object" && data !== null && data.__ifc === IFC_BRAND;
34
+ }
35
+ function createHandshakeRequest(nonce) {
36
+ return { __ifc: IFC_BRAND, type: "handshake-request", nonce };
37
+ }
38
+ function createHandshakeResponse(nonce) {
39
+ return { __ifc: IFC_BRAND, type: "handshake-response", nonce };
40
+ }
41
+ function createRequest(id, method, args) {
42
+ return { __ifc: IFC_BRAND, type: "request", id, method, args };
43
+ }
44
+ function createResponse(id, result) {
45
+ return { __ifc: IFC_BRAND, type: "response", id, result };
46
+ }
47
+ function createError(id, error) {
48
+ return { __ifc: IFC_BRAND, type: "error", id, error };
49
+ }
50
+ function generateId() {
51
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
52
+ return crypto.randomUUID();
53
+ }
54
+ return `ifc-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
55
+ }
56
+
57
+ // src/core/transport.ts
58
+ var Transport = class {
59
+ /**
60
+ * @param target Window to communicate with (iframe.contentWindow or window.parent).
61
+ * @param targetOrigin Expected origin of the remote side. '*' disables origin checks.
62
+ */
63
+ constructor(target, targetOrigin) {
64
+ this.callbacks = /* @__PURE__ */ new Set();
65
+ this.target = target;
66
+ this.targetOrigin = targetOrigin;
67
+ this.handleMessage = (event) => {
68
+ if (this.targetOrigin !== "*" && event.origin !== this.targetOrigin)
69
+ return;
70
+ if (!isProtocolMessage(event.data)) return;
71
+ for (const cb of this.callbacks) {
72
+ cb(event.data, event.origin);
73
+ }
74
+ };
75
+ window.addEventListener("message", this.handleMessage);
76
+ }
77
+ /** Post a protocol message to the remote window. */
78
+ send(message) {
79
+ this.target.postMessage(message, this.targetOrigin);
80
+ }
81
+ /** Subscribe to incoming protocol messages. Returns an unsubscribe function. */
82
+ onMessage(callback) {
83
+ this.callbacks.add(callback);
84
+ return () => {
85
+ this.callbacks.delete(callback);
86
+ };
87
+ }
88
+ /** Remove the event listener and clear all callbacks. */
89
+ destroy() {
90
+ window.removeEventListener("message", this.handleMessage);
91
+ this.callbacks.clear();
92
+ }
93
+ };
94
+
95
+ // src/nodes/parent.ts
96
+ var IframeConnection = class _IframeConnection {
97
+ // ------------------------------------------------------------------
98
+ // Private constructor — use `connect()` or `connectIframe()` instead.
99
+ // ------------------------------------------------------------------
100
+ constructor(transport, callTimeout) {
101
+ this.pendingCalls = /* @__PURE__ */ new Map();
102
+ this.transport = transport;
103
+ this.callTimeout = callTimeout;
104
+ this.remote = this.createProxy();
105
+ this.unsubscribe = this.transport.onMessage(
106
+ (message) => {
107
+ this.handleMessage(message);
108
+ }
109
+ );
110
+ }
111
+ // ------------------------------------------------------------------
112
+ // Static factory — performs the handshake, then returns an instance.
113
+ // ------------------------------------------------------------------
114
+ /**
115
+ * Connect to a child iframe that has called `expose()`.
116
+ *
117
+ * @param iframe The HTMLIFrameElement to communicate with.
118
+ * @param options Connection options (origin, timeouts).
119
+ * @returns Promise resolving with an `IframeConnection` once the handshake succeeds.
120
+ */
121
+ static connect(iframe, options) {
122
+ const {
123
+ targetOrigin,
124
+ handshakeTimeout = 5e3,
125
+ callTimeout = 1e4
126
+ } = options;
127
+ return new Promise((resolve, reject) => {
128
+ const contentWindow = iframe.contentWindow;
129
+ if (!contentWindow) {
130
+ reject(
131
+ new Error(
132
+ "iframe.contentWindow is null. Is the iframe attached to the DOM?"
133
+ )
134
+ );
135
+ return;
136
+ }
137
+ const transport = new Transport(contentWindow, targetOrigin);
138
+ const nonce = generateId();
139
+ let handshakeComplete = false;
140
+ const handshakeTimer = setTimeout(() => {
141
+ if (!handshakeComplete) {
142
+ unsubscribe();
143
+ transport.destroy();
144
+ reject(
145
+ new Error(
146
+ `Handshake timed out after ${handshakeTimeout}ms.`
147
+ )
148
+ );
149
+ }
150
+ }, handshakeTimeout);
151
+ const unsubscribe = transport.onMessage(
152
+ (message) => {
153
+ if (message.type === "handshake-response" && message.nonce === nonce && !handshakeComplete) {
154
+ handshakeComplete = true;
155
+ clearTimeout(handshakeTimer);
156
+ unsubscribe();
157
+ resolve(
158
+ new _IframeConnection(transport, callTimeout)
159
+ );
160
+ }
161
+ }
162
+ );
163
+ iframe.addEventListener("load", () => {
164
+ transport.send(createHandshakeRequest(nonce));
165
+ });
166
+ });
167
+ }
168
+ // ------------------------------------------------------------------
169
+ // Message handling (post-handshake: responses & errors only)
170
+ // ------------------------------------------------------------------
171
+ handleMessage(message) {
172
+ switch (message.type) {
173
+ case "response": {
174
+ this.settlePendingCall(message.id)?.resolve(message.result);
175
+ break;
176
+ }
177
+ case "error": {
178
+ this.settlePendingCall(message.id)?.reject(
179
+ new Error(message.error)
180
+ );
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ // ------------------------------------------------------------------
186
+ // Pending-call helpers
187
+ // ------------------------------------------------------------------
188
+ /**
189
+ * Extracts and cleans up a pending call by ID.
190
+ * Returns the call so the caller can resolve or reject it, or undefined
191
+ * if no call with that ID exists (e.g. already settled or timed out).
192
+ */
193
+ settlePendingCall(id) {
194
+ const pending = this.pendingCalls.get(id);
195
+ if (!pending) return void 0;
196
+ clearTimeout(pending.timer);
197
+ this.pendingCalls.delete(id);
198
+ return pending;
199
+ }
200
+ // ------------------------------------------------------------------
201
+ // Proxy creation
202
+ // ------------------------------------------------------------------
203
+ /**
204
+ * Builds the Proxy that translates property access into RPC calls.
205
+ * Each method call returns a Promise that resolves when the child responds.
206
+ */
207
+ createProxy() {
208
+ return new Proxy({}, {
209
+ get: (_target, prop) => {
210
+ if (typeof prop !== "string") return void 0;
211
+ return (...args) => new Promise((resolve, reject) => {
212
+ const id = generateId();
213
+ const timer = setTimeout(() => {
214
+ this.pendingCalls.delete(id);
215
+ reject(
216
+ new Error(
217
+ `Call to "${prop}" timed out after ${this.callTimeout}ms.`
218
+ )
219
+ );
220
+ }, this.callTimeout);
221
+ this.pendingCalls.set(id, { resolve, reject, timer });
222
+ this.transport.send(createRequest(id, prop, args));
223
+ });
224
+ }
225
+ });
226
+ }
227
+ // ------------------------------------------------------------------
228
+ // Cleanup
229
+ // ------------------------------------------------------------------
230
+ /** Tear down the connection: removes listeners, rejects pending calls. */
231
+ destroy() {
232
+ for (const [, pending] of this.pendingCalls) {
233
+ clearTimeout(pending.timer);
234
+ pending.reject(new Error("Connection destroyed."));
235
+ }
236
+ this.pendingCalls.clear();
237
+ this.unsubscribe();
238
+ this.transport.destroy();
239
+ }
240
+ };
241
+ function connectIframe(iframe, options) {
242
+ return IframeConnection.connect(iframe, options);
243
+ }
244
+
245
+ // src/nodes/child.ts
246
+ var IframeExposed = class {
247
+ constructor(api, options) {
248
+ this.api = api;
249
+ const { allowedOrigin } = options;
250
+ this.transport = new Transport(window.parent, allowedOrigin);
251
+ this.unsubscribe = this.transport.onMessage(
252
+ (message) => {
253
+ this.handleMessage(message);
254
+ }
255
+ );
256
+ }
257
+ // ------------------------------------------------------------------
258
+ // Message handling
259
+ // ------------------------------------------------------------------
260
+ handleMessage(message) {
261
+ switch (message.type) {
262
+ case "handshake-request": {
263
+ this.transport.send(createHandshakeResponse(message.nonce));
264
+ break;
265
+ }
266
+ case "request": {
267
+ this.dispatchRequest(message.id, message.method, message.args);
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ // ------------------------------------------------------------------
273
+ // RPC dispatch
274
+ // ------------------------------------------------------------------
275
+ /**
276
+ * Validates and executes an RPC request, sending back the result or error.
277
+ *
278
+ * Extracted into its own method for clarity: the message handler stays
279
+ * focused on routing, while this method handles validation + execution.
280
+ */
281
+ async dispatchRequest(id, method, args) {
282
+ if (!Object.prototype.hasOwnProperty.call(this.api, method)) {
283
+ this.transport.send(
284
+ createError(id, `Method "${method}" is not exposed.`)
285
+ );
286
+ return;
287
+ }
288
+ const fn = this.api[method];
289
+ if (typeof fn !== "function") {
290
+ this.transport.send(
291
+ createError(id, `"${method}" is not a function.`)
292
+ );
293
+ return;
294
+ }
295
+ try {
296
+ const result = await fn.apply(this.api, args);
297
+ this.transport.send(createResponse(id, result));
298
+ } catch (err) {
299
+ const errorMessage = err instanceof Error ? err.message : String(err);
300
+ this.transport.send(createError(id, errorMessage));
301
+ }
302
+ }
303
+ // ------------------------------------------------------------------
304
+ // Cleanup
305
+ // ------------------------------------------------------------------
306
+ /** Remove all listeners and stop responding to RPC calls. */
307
+ destroy() {
308
+ this.unsubscribe();
309
+ this.transport.destroy();
310
+ }
311
+ };
312
+ function expose(api, options) {
313
+ return new IframeExposed(api, options);
314
+ }
315
+ // Annotate the CommonJS export names for ESM import in node:
316
+ 0 && (module.exports = {
317
+ IframeConnection,
318
+ IframeExposed,
319
+ connectIframe,
320
+ expose
321
+ });
@@ -0,0 +1,248 @@
1
+ /**
2
+ * types.ts - Public type definitions for iFrameConnector
3
+ *
4
+ * Defines user-facing type contracts: API shape constraints, configuration
5
+ * options for parent and child, and the remote proxy type.
6
+ *
7
+ * Key design decisions:
8
+ * - ApiMethods constrains all methods to return Promise<unknown>, enforcing
9
+ * asynchronous RPC (postMessage is inherently async).
10
+ * - RemoteApi maps the child's API type 1:1 so the parent gets full
11
+ * autocompletion and type safety on the proxy.
12
+ * - Origin options are mandatory (no default), making the secure path explicit.
13
+ */
14
+ /**
15
+ * Constraint for methods that can be exposed via RPC.
16
+ * Every method must return a Promise — synchronous functions are not allowed
17
+ * because postMessage communication is inherently asynchronous.
18
+ *
19
+ * `any[]` is intentional: it lets consumers define specific parameter types
20
+ * in their own interfaces while keeping this base constraint flexible.
21
+ */
22
+ type ApiMethods = Record<string, (...args: any[]) => Promise<unknown>>;
23
+ /**
24
+ * Maps an API definition to its remote (proxy) representation.
25
+ * Preserves method signatures so the parent gets full type safety
26
+ * and autocompletion when calling methods through the proxy.
27
+ */
28
+ type RemoteApi<T extends ApiMethods> = {
29
+ [K in keyof T]: T[K];
30
+ };
31
+ /** Options for the parent when connecting to a child iframe. */
32
+ interface ConnectOptions {
33
+ /**
34
+ * Expected origin of the child iframe (e.g. 'https://child.example.com').
35
+ * Messages from any other origin are silently discarded.
36
+ * Use '*' to accept any origin (not recommended for production).
37
+ */
38
+ targetOrigin: string;
39
+ /** Max wait time (ms) for the handshake to complete. @default 5000 */
40
+ handshakeTimeout?: number;
41
+ /** Max wait time (ms) for each individual RPC call. @default 10000 */
42
+ callTimeout?: number;
43
+ }
44
+ /** Options for the child when exposing its API. */
45
+ interface ExposeOptions {
46
+ /**
47
+ * Allowed parent origin (e.g. 'https://parent.example.com').
48
+ * Requests from any other origin are silently ignored.
49
+ * Use '*' to accept any origin (not recommended for production).
50
+ */
51
+ allowedOrigin: string;
52
+ }
53
+
54
+ /**
55
+ * parent.ts - Parent side of iFrameConnector
56
+ *
57
+ * Provides `IframeConnection` class and `connectIframe()` factory for
58
+ * establishing a connection to a child iframe that has called `expose()`.
59
+ *
60
+ * Connection flow:
61
+ * 1. `IframeConnection.connect()` sends a HandshakeRequest with a random
62
+ * nonce to the child.
63
+ * 2. Waits for HandshakeResponse echoing the same nonce (with timeout).
64
+ * 3. Returns an `IframeConnection` instance whose `remote` Proxy turns
65
+ * method calls into RPC requests resolved by the child's responses.
66
+ *
67
+ * Security:
68
+ * - The nonce prevents rogue iframes from faking readiness.
69
+ * - Origin validation is handled by Transport.
70
+ * - Each pending call has its own timeout to prevent memory leaks.
71
+ *
72
+ * OOP rationale:
73
+ * - Encapsulating transport, pending-call state, and the Proxy inside a class
74
+ * eliminates loose closures and makes the lifecycle (connect → call → destroy)
75
+ * explicit. The static factory `connect()` cleanly separates the async
76
+ * handshake phase from the synchronous RPC phase.
77
+ */
78
+
79
+ /** Object returned by `connectIframe` / `IframeConnection.connect`. */
80
+ interface Connection<T extends ApiMethods> {
81
+ /** Proxy — call remote methods as if they were local. */
82
+ remote: RemoteApi<T>;
83
+ /** Tear down the connection: removes listeners, rejects pending calls. */
84
+ destroy: () => void;
85
+ }
86
+ /**
87
+ * Manages a parent-side connection to a child iframe.
88
+ *
89
+ * Use the static `connect()` factory (or the `connectIframe()` helper) to
90
+ * create an instance — the constructor is private because connection setup
91
+ * requires an async handshake.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * const conn = await IframeConnection.connect<ChildApi>(iframe, {
96
+ * targetOrigin: 'https://child.example.com',
97
+ * });
98
+ * const result = await conn.remote.greet('World');
99
+ * conn.destroy();
100
+ * ```
101
+ */
102
+ declare class IframeConnection<T extends ApiMethods> implements Connection<T> {
103
+ /** Proxy — call remote methods as if they were local. */
104
+ readonly remote: RemoteApi<T>;
105
+ private readonly transport;
106
+ private readonly pendingCalls;
107
+ private readonly callTimeout;
108
+ private readonly unsubscribe;
109
+ private constructor();
110
+ /**
111
+ * Connect to a child iframe that has called `expose()`.
112
+ *
113
+ * @param iframe The HTMLIFrameElement to communicate with.
114
+ * @param options Connection options (origin, timeouts).
115
+ * @returns Promise resolving with an `IframeConnection` once the handshake succeeds.
116
+ */
117
+ static connect<T extends ApiMethods>(iframe: HTMLIFrameElement, options: ConnectOptions): Promise<IframeConnection<T>>;
118
+ private handleMessage;
119
+ /**
120
+ * Extracts and cleans up a pending call by ID.
121
+ * Returns the call so the caller can resolve or reject it, or undefined
122
+ * if no call with that ID exists (e.g. already settled or timed out).
123
+ */
124
+ private settlePendingCall;
125
+ /**
126
+ * Builds the Proxy that translates property access into RPC calls.
127
+ * Each method call returns a Promise that resolves when the child responds.
128
+ */
129
+ private createProxy;
130
+ /** Tear down the connection: removes listeners, rejects pending calls. */
131
+ destroy(): void;
132
+ }
133
+ /**
134
+ * Connect to a child iframe that has called `expose()`.
135
+ *
136
+ * Thin wrapper around `IframeConnection.connect()` that preserves the
137
+ * original functional API.
138
+ *
139
+ * @param iframe The HTMLIFrameElement to communicate with.
140
+ * @param options Connection options (origin, timeouts).
141
+ * @returns Promise resolving with a `Connection` once the handshake succeeds.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * import { connectIframe } from 'rpc-iframe';
146
+ *
147
+ * interface ChildApi {
148
+ * greet(name: string): Promise<string>;
149
+ * add(a: number, b: number): Promise<number>;
150
+ * }
151
+ *
152
+ * const iframe = document.getElementById('my-iframe') as HTMLIFrameElement;
153
+ * const { remote, destroy } = await connectIframe<ChildApi>(iframe, {
154
+ * targetOrigin: 'https://child.example.com',
155
+ * });
156
+ *
157
+ * const greeting = await remote.greet('World');
158
+ * console.log(greeting); // "Hello, World!"
159
+ * ```
160
+ */
161
+ declare function connectIframe<T extends ApiMethods>(iframe: HTMLIFrameElement, options: ConnectOptions): Promise<Connection<T>>;
162
+
163
+ /**
164
+ * child.ts - Child iframe side of iFrameConnector
165
+ *
166
+ * Provides `IframeExposed` class and `expose()` factory for registering
167
+ * callable methods for the parent frame.
168
+ *
169
+ * Flow:
170
+ * 1. Listen for HandshakeRequest from parent → respond with HandshakeResponse.
171
+ * 2. Listen for RPC requests → dispatch to the registered method, reply with
172
+ * result or error.
173
+ *
174
+ * Security:
175
+ * - Only own-property methods on the `api` object can be invoked (blocks
176
+ * prototype pollution attacks like calling `constructor` or `toString`).
177
+ * - Origin is validated at the Transport level.
178
+ * - Error stack traces are intentionally omitted from responses to avoid
179
+ * leaking internal details across origins.
180
+ *
181
+ * OOP rationale:
182
+ * - Encapsulating the API reference, transport, and dispatch logic in a class
183
+ * makes ownership of resources explicit and simplifies cleanup via `destroy()`.
184
+ */
185
+
186
+ /** Handle returned by `expose()` / `IframeExposed` for cleanup. */
187
+ interface ExposeHandle {
188
+ /** Remove all listeners and stop responding to RPC calls. */
189
+ destroy: () => void;
190
+ }
191
+ /**
192
+ * Manages the child-side of an iframe RPC connection.
193
+ *
194
+ * Registers an API object whose methods can be invoked by the parent frame.
195
+ * Handles the handshake response and dispatches incoming RPC requests.
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * const handle = new IframeExposed({
200
+ * async greet(name: string) { return `Hello, ${name}!`; },
201
+ * async add(a: number, b: number) { return a + b; },
202
+ * }, { allowedOrigin: 'https://parent.example.com' });
203
+ *
204
+ * // Later:
205
+ * handle.destroy();
206
+ * ```
207
+ */
208
+ declare class IframeExposed<T extends ApiMethods> implements ExposeHandle {
209
+ private readonly api;
210
+ private readonly transport;
211
+ private readonly unsubscribe;
212
+ constructor(api: T, options: ExposeOptions);
213
+ private handleMessage;
214
+ /**
215
+ * Validates and executes an RPC request, sending back the result or error.
216
+ *
217
+ * Extracted into its own method for clarity: the message handler stays
218
+ * focused on routing, while this method handles validation + execution.
219
+ */
220
+ private dispatchRequest;
221
+ /** Remove all listeners and stop responding to RPC calls. */
222
+ destroy(): void;
223
+ }
224
+ /**
225
+ * Expose an API object so the parent frame can call its methods via RPC.
226
+ *
227
+ * Thin wrapper around `new IframeExposed()` that preserves the original
228
+ * functional API.
229
+ *
230
+ * @param api Object whose values are async functions.
231
+ * @param options Configuration — at minimum the allowed parent origin.
232
+ * @returns Handle with a `destroy()` method for cleanup.
233
+ *
234
+ * @example
235
+ * ```ts
236
+ * import { expose } from 'iframeconnector';
237
+ *
238
+ * const api = {
239
+ * async greet(name: string) { return `Hello, ${name}!`; },
240
+ * async add(a: number, b: number) { return a + b; },
241
+ * };
242
+ *
243
+ * expose(api, { allowedOrigin: 'https://parent.example.com' });
244
+ * ```
245
+ */
246
+ declare function expose<T extends ApiMethods>(api: T, options: ExposeOptions): ExposeHandle;
247
+
248
+ export { type ApiMethods, type ConnectOptions, type Connection, type ExposeHandle, type ExposeOptions, IframeConnection, IframeExposed, type RemoteApi, connectIframe, expose };
@@ -0,0 +1,248 @@
1
+ /**
2
+ * types.ts - Public type definitions for iFrameConnector
3
+ *
4
+ * Defines user-facing type contracts: API shape constraints, configuration
5
+ * options for parent and child, and the remote proxy type.
6
+ *
7
+ * Key design decisions:
8
+ * - ApiMethods constrains all methods to return Promise<unknown>, enforcing
9
+ * asynchronous RPC (postMessage is inherently async).
10
+ * - RemoteApi maps the child's API type 1:1 so the parent gets full
11
+ * autocompletion and type safety on the proxy.
12
+ * - Origin options are mandatory (no default), making the secure path explicit.
13
+ */
14
+ /**
15
+ * Constraint for methods that can be exposed via RPC.
16
+ * Every method must return a Promise — synchronous functions are not allowed
17
+ * because postMessage communication is inherently asynchronous.
18
+ *
19
+ * `any[]` is intentional: it lets consumers define specific parameter types
20
+ * in their own interfaces while keeping this base constraint flexible.
21
+ */
22
+ type ApiMethods = Record<string, (...args: any[]) => Promise<unknown>>;
23
+ /**
24
+ * Maps an API definition to its remote (proxy) representation.
25
+ * Preserves method signatures so the parent gets full type safety
26
+ * and autocompletion when calling methods through the proxy.
27
+ */
28
+ type RemoteApi<T extends ApiMethods> = {
29
+ [K in keyof T]: T[K];
30
+ };
31
+ /** Options for the parent when connecting to a child iframe. */
32
+ interface ConnectOptions {
33
+ /**
34
+ * Expected origin of the child iframe (e.g. 'https://child.example.com').
35
+ * Messages from any other origin are silently discarded.
36
+ * Use '*' to accept any origin (not recommended for production).
37
+ */
38
+ targetOrigin: string;
39
+ /** Max wait time (ms) for the handshake to complete. @default 5000 */
40
+ handshakeTimeout?: number;
41
+ /** Max wait time (ms) for each individual RPC call. @default 10000 */
42
+ callTimeout?: number;
43
+ }
44
+ /** Options for the child when exposing its API. */
45
+ interface ExposeOptions {
46
+ /**
47
+ * Allowed parent origin (e.g. 'https://parent.example.com').
48
+ * Requests from any other origin are silently ignored.
49
+ * Use '*' to accept any origin (not recommended for production).
50
+ */
51
+ allowedOrigin: string;
52
+ }
53
+
54
+ /**
55
+ * parent.ts - Parent side of iFrameConnector
56
+ *
57
+ * Provides `IframeConnection` class and `connectIframe()` factory for
58
+ * establishing a connection to a child iframe that has called `expose()`.
59
+ *
60
+ * Connection flow:
61
+ * 1. `IframeConnection.connect()` sends a HandshakeRequest with a random
62
+ * nonce to the child.
63
+ * 2. Waits for HandshakeResponse echoing the same nonce (with timeout).
64
+ * 3. Returns an `IframeConnection` instance whose `remote` Proxy turns
65
+ * method calls into RPC requests resolved by the child's responses.
66
+ *
67
+ * Security:
68
+ * - The nonce prevents rogue iframes from faking readiness.
69
+ * - Origin validation is handled by Transport.
70
+ * - Each pending call has its own timeout to prevent memory leaks.
71
+ *
72
+ * OOP rationale:
73
+ * - Encapsulating transport, pending-call state, and the Proxy inside a class
74
+ * eliminates loose closures and makes the lifecycle (connect → call → destroy)
75
+ * explicit. The static factory `connect()` cleanly separates the async
76
+ * handshake phase from the synchronous RPC phase.
77
+ */
78
+
79
+ /** Object returned by `connectIframe` / `IframeConnection.connect`. */
80
+ interface Connection<T extends ApiMethods> {
81
+ /** Proxy — call remote methods as if they were local. */
82
+ remote: RemoteApi<T>;
83
+ /** Tear down the connection: removes listeners, rejects pending calls. */
84
+ destroy: () => void;
85
+ }
86
+ /**
87
+ * Manages a parent-side connection to a child iframe.
88
+ *
89
+ * Use the static `connect()` factory (or the `connectIframe()` helper) to
90
+ * create an instance — the constructor is private because connection setup
91
+ * requires an async handshake.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * const conn = await IframeConnection.connect<ChildApi>(iframe, {
96
+ * targetOrigin: 'https://child.example.com',
97
+ * });
98
+ * const result = await conn.remote.greet('World');
99
+ * conn.destroy();
100
+ * ```
101
+ */
102
+ declare class IframeConnection<T extends ApiMethods> implements Connection<T> {
103
+ /** Proxy — call remote methods as if they were local. */
104
+ readonly remote: RemoteApi<T>;
105
+ private readonly transport;
106
+ private readonly pendingCalls;
107
+ private readonly callTimeout;
108
+ private readonly unsubscribe;
109
+ private constructor();
110
+ /**
111
+ * Connect to a child iframe that has called `expose()`.
112
+ *
113
+ * @param iframe The HTMLIFrameElement to communicate with.
114
+ * @param options Connection options (origin, timeouts).
115
+ * @returns Promise resolving with an `IframeConnection` once the handshake succeeds.
116
+ */
117
+ static connect<T extends ApiMethods>(iframe: HTMLIFrameElement, options: ConnectOptions): Promise<IframeConnection<T>>;
118
+ private handleMessage;
119
+ /**
120
+ * Extracts and cleans up a pending call by ID.
121
+ * Returns the call so the caller can resolve or reject it, or undefined
122
+ * if no call with that ID exists (e.g. already settled or timed out).
123
+ */
124
+ private settlePendingCall;
125
+ /**
126
+ * Builds the Proxy that translates property access into RPC calls.
127
+ * Each method call returns a Promise that resolves when the child responds.
128
+ */
129
+ private createProxy;
130
+ /** Tear down the connection: removes listeners, rejects pending calls. */
131
+ destroy(): void;
132
+ }
133
+ /**
134
+ * Connect to a child iframe that has called `expose()`.
135
+ *
136
+ * Thin wrapper around `IframeConnection.connect()` that preserves the
137
+ * original functional API.
138
+ *
139
+ * @param iframe The HTMLIFrameElement to communicate with.
140
+ * @param options Connection options (origin, timeouts).
141
+ * @returns Promise resolving with a `Connection` once the handshake succeeds.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * import { connectIframe } from 'rpc-iframe';
146
+ *
147
+ * interface ChildApi {
148
+ * greet(name: string): Promise<string>;
149
+ * add(a: number, b: number): Promise<number>;
150
+ * }
151
+ *
152
+ * const iframe = document.getElementById('my-iframe') as HTMLIFrameElement;
153
+ * const { remote, destroy } = await connectIframe<ChildApi>(iframe, {
154
+ * targetOrigin: 'https://child.example.com',
155
+ * });
156
+ *
157
+ * const greeting = await remote.greet('World');
158
+ * console.log(greeting); // "Hello, World!"
159
+ * ```
160
+ */
161
+ declare function connectIframe<T extends ApiMethods>(iframe: HTMLIFrameElement, options: ConnectOptions): Promise<Connection<T>>;
162
+
163
+ /**
164
+ * child.ts - Child iframe side of iFrameConnector
165
+ *
166
+ * Provides `IframeExposed` class and `expose()` factory for registering
167
+ * callable methods for the parent frame.
168
+ *
169
+ * Flow:
170
+ * 1. Listen for HandshakeRequest from parent → respond with HandshakeResponse.
171
+ * 2. Listen for RPC requests → dispatch to the registered method, reply with
172
+ * result or error.
173
+ *
174
+ * Security:
175
+ * - Only own-property methods on the `api` object can be invoked (blocks
176
+ * prototype pollution attacks like calling `constructor` or `toString`).
177
+ * - Origin is validated at the Transport level.
178
+ * - Error stack traces are intentionally omitted from responses to avoid
179
+ * leaking internal details across origins.
180
+ *
181
+ * OOP rationale:
182
+ * - Encapsulating the API reference, transport, and dispatch logic in a class
183
+ * makes ownership of resources explicit and simplifies cleanup via `destroy()`.
184
+ */
185
+
186
+ /** Handle returned by `expose()` / `IframeExposed` for cleanup. */
187
+ interface ExposeHandle {
188
+ /** Remove all listeners and stop responding to RPC calls. */
189
+ destroy: () => void;
190
+ }
191
+ /**
192
+ * Manages the child-side of an iframe RPC connection.
193
+ *
194
+ * Registers an API object whose methods can be invoked by the parent frame.
195
+ * Handles the handshake response and dispatches incoming RPC requests.
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * const handle = new IframeExposed({
200
+ * async greet(name: string) { return `Hello, ${name}!`; },
201
+ * async add(a: number, b: number) { return a + b; },
202
+ * }, { allowedOrigin: 'https://parent.example.com' });
203
+ *
204
+ * // Later:
205
+ * handle.destroy();
206
+ * ```
207
+ */
208
+ declare class IframeExposed<T extends ApiMethods> implements ExposeHandle {
209
+ private readonly api;
210
+ private readonly transport;
211
+ private readonly unsubscribe;
212
+ constructor(api: T, options: ExposeOptions);
213
+ private handleMessage;
214
+ /**
215
+ * Validates and executes an RPC request, sending back the result or error.
216
+ *
217
+ * Extracted into its own method for clarity: the message handler stays
218
+ * focused on routing, while this method handles validation + execution.
219
+ */
220
+ private dispatchRequest;
221
+ /** Remove all listeners and stop responding to RPC calls. */
222
+ destroy(): void;
223
+ }
224
+ /**
225
+ * Expose an API object so the parent frame can call its methods via RPC.
226
+ *
227
+ * Thin wrapper around `new IframeExposed()` that preserves the original
228
+ * functional API.
229
+ *
230
+ * @param api Object whose values are async functions.
231
+ * @param options Configuration — at minimum the allowed parent origin.
232
+ * @returns Handle with a `destroy()` method for cleanup.
233
+ *
234
+ * @example
235
+ * ```ts
236
+ * import { expose } from 'iframeconnector';
237
+ *
238
+ * const api = {
239
+ * async greet(name: string) { return `Hello, ${name}!`; },
240
+ * async add(a: number, b: number) { return a + b; },
241
+ * };
242
+ *
243
+ * expose(api, { allowedOrigin: 'https://parent.example.com' });
244
+ * ```
245
+ */
246
+ declare function expose<T extends ApiMethods>(api: T, options: ExposeOptions): ExposeHandle;
247
+
248
+ export { type ApiMethods, type ConnectOptions, type Connection, type ExposeHandle, type ExposeOptions, IframeConnection, IframeExposed, type RemoteApi, connectIframe, expose };
package/dist/index.js ADDED
@@ -0,0 +1,291 @@
1
+ // src/core/protocol.ts
2
+ var IFC_BRAND = "__ifc__";
3
+ function isProtocolMessage(data) {
4
+ return typeof data === "object" && data !== null && data.__ifc === IFC_BRAND;
5
+ }
6
+ function createHandshakeRequest(nonce) {
7
+ return { __ifc: IFC_BRAND, type: "handshake-request", nonce };
8
+ }
9
+ function createHandshakeResponse(nonce) {
10
+ return { __ifc: IFC_BRAND, type: "handshake-response", nonce };
11
+ }
12
+ function createRequest(id, method, args) {
13
+ return { __ifc: IFC_BRAND, type: "request", id, method, args };
14
+ }
15
+ function createResponse(id, result) {
16
+ return { __ifc: IFC_BRAND, type: "response", id, result };
17
+ }
18
+ function createError(id, error) {
19
+ return { __ifc: IFC_BRAND, type: "error", id, error };
20
+ }
21
+ function generateId() {
22
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
23
+ return crypto.randomUUID();
24
+ }
25
+ return `ifc-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
26
+ }
27
+
28
+ // src/core/transport.ts
29
+ var Transport = class {
30
+ /**
31
+ * @param target Window to communicate with (iframe.contentWindow or window.parent).
32
+ * @param targetOrigin Expected origin of the remote side. '*' disables origin checks.
33
+ */
34
+ constructor(target, targetOrigin) {
35
+ this.callbacks = /* @__PURE__ */ new Set();
36
+ this.target = target;
37
+ this.targetOrigin = targetOrigin;
38
+ this.handleMessage = (event) => {
39
+ if (this.targetOrigin !== "*" && event.origin !== this.targetOrigin)
40
+ return;
41
+ if (!isProtocolMessage(event.data)) return;
42
+ for (const cb of this.callbacks) {
43
+ cb(event.data, event.origin);
44
+ }
45
+ };
46
+ window.addEventListener("message", this.handleMessage);
47
+ }
48
+ /** Post a protocol message to the remote window. */
49
+ send(message) {
50
+ this.target.postMessage(message, this.targetOrigin);
51
+ }
52
+ /** Subscribe to incoming protocol messages. Returns an unsubscribe function. */
53
+ onMessage(callback) {
54
+ this.callbacks.add(callback);
55
+ return () => {
56
+ this.callbacks.delete(callback);
57
+ };
58
+ }
59
+ /** Remove the event listener and clear all callbacks. */
60
+ destroy() {
61
+ window.removeEventListener("message", this.handleMessage);
62
+ this.callbacks.clear();
63
+ }
64
+ };
65
+
66
+ // src/nodes/parent.ts
67
+ var IframeConnection = class _IframeConnection {
68
+ // ------------------------------------------------------------------
69
+ // Private constructor — use `connect()` or `connectIframe()` instead.
70
+ // ------------------------------------------------------------------
71
+ constructor(transport, callTimeout) {
72
+ this.pendingCalls = /* @__PURE__ */ new Map();
73
+ this.transport = transport;
74
+ this.callTimeout = callTimeout;
75
+ this.remote = this.createProxy();
76
+ this.unsubscribe = this.transport.onMessage(
77
+ (message) => {
78
+ this.handleMessage(message);
79
+ }
80
+ );
81
+ }
82
+ // ------------------------------------------------------------------
83
+ // Static factory — performs the handshake, then returns an instance.
84
+ // ------------------------------------------------------------------
85
+ /**
86
+ * Connect to a child iframe that has called `expose()`.
87
+ *
88
+ * @param iframe The HTMLIFrameElement to communicate with.
89
+ * @param options Connection options (origin, timeouts).
90
+ * @returns Promise resolving with an `IframeConnection` once the handshake succeeds.
91
+ */
92
+ static connect(iframe, options) {
93
+ const {
94
+ targetOrigin,
95
+ handshakeTimeout = 5e3,
96
+ callTimeout = 1e4
97
+ } = options;
98
+ return new Promise((resolve, reject) => {
99
+ const contentWindow = iframe.contentWindow;
100
+ if (!contentWindow) {
101
+ reject(
102
+ new Error(
103
+ "iframe.contentWindow is null. Is the iframe attached to the DOM?"
104
+ )
105
+ );
106
+ return;
107
+ }
108
+ const transport = new Transport(contentWindow, targetOrigin);
109
+ const nonce = generateId();
110
+ let handshakeComplete = false;
111
+ const handshakeTimer = setTimeout(() => {
112
+ if (!handshakeComplete) {
113
+ unsubscribe();
114
+ transport.destroy();
115
+ reject(
116
+ new Error(
117
+ `Handshake timed out after ${handshakeTimeout}ms.`
118
+ )
119
+ );
120
+ }
121
+ }, handshakeTimeout);
122
+ const unsubscribe = transport.onMessage(
123
+ (message) => {
124
+ if (message.type === "handshake-response" && message.nonce === nonce && !handshakeComplete) {
125
+ handshakeComplete = true;
126
+ clearTimeout(handshakeTimer);
127
+ unsubscribe();
128
+ resolve(
129
+ new _IframeConnection(transport, callTimeout)
130
+ );
131
+ }
132
+ }
133
+ );
134
+ iframe.addEventListener("load", () => {
135
+ transport.send(createHandshakeRequest(nonce));
136
+ });
137
+ });
138
+ }
139
+ // ------------------------------------------------------------------
140
+ // Message handling (post-handshake: responses & errors only)
141
+ // ------------------------------------------------------------------
142
+ handleMessage(message) {
143
+ switch (message.type) {
144
+ case "response": {
145
+ this.settlePendingCall(message.id)?.resolve(message.result);
146
+ break;
147
+ }
148
+ case "error": {
149
+ this.settlePendingCall(message.id)?.reject(
150
+ new Error(message.error)
151
+ );
152
+ break;
153
+ }
154
+ }
155
+ }
156
+ // ------------------------------------------------------------------
157
+ // Pending-call helpers
158
+ // ------------------------------------------------------------------
159
+ /**
160
+ * Extracts and cleans up a pending call by ID.
161
+ * Returns the call so the caller can resolve or reject it, or undefined
162
+ * if no call with that ID exists (e.g. already settled or timed out).
163
+ */
164
+ settlePendingCall(id) {
165
+ const pending = this.pendingCalls.get(id);
166
+ if (!pending) return void 0;
167
+ clearTimeout(pending.timer);
168
+ this.pendingCalls.delete(id);
169
+ return pending;
170
+ }
171
+ // ------------------------------------------------------------------
172
+ // Proxy creation
173
+ // ------------------------------------------------------------------
174
+ /**
175
+ * Builds the Proxy that translates property access into RPC calls.
176
+ * Each method call returns a Promise that resolves when the child responds.
177
+ */
178
+ createProxy() {
179
+ return new Proxy({}, {
180
+ get: (_target, prop) => {
181
+ if (typeof prop !== "string") return void 0;
182
+ return (...args) => new Promise((resolve, reject) => {
183
+ const id = generateId();
184
+ const timer = setTimeout(() => {
185
+ this.pendingCalls.delete(id);
186
+ reject(
187
+ new Error(
188
+ `Call to "${prop}" timed out after ${this.callTimeout}ms.`
189
+ )
190
+ );
191
+ }, this.callTimeout);
192
+ this.pendingCalls.set(id, { resolve, reject, timer });
193
+ this.transport.send(createRequest(id, prop, args));
194
+ });
195
+ }
196
+ });
197
+ }
198
+ // ------------------------------------------------------------------
199
+ // Cleanup
200
+ // ------------------------------------------------------------------
201
+ /** Tear down the connection: removes listeners, rejects pending calls. */
202
+ destroy() {
203
+ for (const [, pending] of this.pendingCalls) {
204
+ clearTimeout(pending.timer);
205
+ pending.reject(new Error("Connection destroyed."));
206
+ }
207
+ this.pendingCalls.clear();
208
+ this.unsubscribe();
209
+ this.transport.destroy();
210
+ }
211
+ };
212
+ function connectIframe(iframe, options) {
213
+ return IframeConnection.connect(iframe, options);
214
+ }
215
+
216
+ // src/nodes/child.ts
217
+ var IframeExposed = class {
218
+ constructor(api, options) {
219
+ this.api = api;
220
+ const { allowedOrigin } = options;
221
+ this.transport = new Transport(window.parent, allowedOrigin);
222
+ this.unsubscribe = this.transport.onMessage(
223
+ (message) => {
224
+ this.handleMessage(message);
225
+ }
226
+ );
227
+ }
228
+ // ------------------------------------------------------------------
229
+ // Message handling
230
+ // ------------------------------------------------------------------
231
+ handleMessage(message) {
232
+ switch (message.type) {
233
+ case "handshake-request": {
234
+ this.transport.send(createHandshakeResponse(message.nonce));
235
+ break;
236
+ }
237
+ case "request": {
238
+ this.dispatchRequest(message.id, message.method, message.args);
239
+ break;
240
+ }
241
+ }
242
+ }
243
+ // ------------------------------------------------------------------
244
+ // RPC dispatch
245
+ // ------------------------------------------------------------------
246
+ /**
247
+ * Validates and executes an RPC request, sending back the result or error.
248
+ *
249
+ * Extracted into its own method for clarity: the message handler stays
250
+ * focused on routing, while this method handles validation + execution.
251
+ */
252
+ async dispatchRequest(id, method, args) {
253
+ if (!Object.prototype.hasOwnProperty.call(this.api, method)) {
254
+ this.transport.send(
255
+ createError(id, `Method "${method}" is not exposed.`)
256
+ );
257
+ return;
258
+ }
259
+ const fn = this.api[method];
260
+ if (typeof fn !== "function") {
261
+ this.transport.send(
262
+ createError(id, `"${method}" is not a function.`)
263
+ );
264
+ return;
265
+ }
266
+ try {
267
+ const result = await fn.apply(this.api, args);
268
+ this.transport.send(createResponse(id, result));
269
+ } catch (err) {
270
+ const errorMessage = err instanceof Error ? err.message : String(err);
271
+ this.transport.send(createError(id, errorMessage));
272
+ }
273
+ }
274
+ // ------------------------------------------------------------------
275
+ // Cleanup
276
+ // ------------------------------------------------------------------
277
+ /** Remove all listeners and stop responding to RPC calls. */
278
+ destroy() {
279
+ this.unsubscribe();
280
+ this.transport.destroy();
281
+ }
282
+ };
283
+ function expose(api, options) {
284
+ return new IframeExposed(api, options);
285
+ }
286
+ export {
287
+ IframeConnection,
288
+ IframeExposed,
289
+ connectIframe,
290
+ expose
291
+ };
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "rpc-iframe",
3
+ "version": "0.2.0",
4
+ "description": "Modern JS/TS library for RPC-style communication between parent and iframe",
5
+ "license": "MIT",
6
+ "author": "AdriAir",
7
+ "main": "dist/index.cjs",
8
+ "module": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "type": "module",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "types": {
20
+ "import": "./dist/index.d.ts",
21
+ "require": "./dist/index.d.cts"
22
+ },
23
+ "import": "./dist/index.js",
24
+ "require": "./dist/index.cjs"
25
+ }
26
+ },
27
+ "engines": {
28
+ "node": ">=16"
29
+ },
30
+ "keywords": [
31
+ "iframe",
32
+ "rpc",
33
+ "postmessage",
34
+ "typescript",
35
+ "javascript",
36
+ "browser",
37
+ "cross-window",
38
+ "ipc",
39
+ "frontend"
40
+ ],
41
+ "scripts": {
42
+ "build": "tsup src/index.ts --format esm,cjs --dts",
43
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
44
+ "typecheck": "tsc --noEmit",
45
+ "clean": "rimraf dist",
46
+ "serve": "serve examples/",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest",
49
+ "test:ui": "vitest --ui",
50
+ "test:coverage": "vitest run --coverage",
51
+ "lint": "eslint .",
52
+ "lint:fix": "eslint . --fix",
53
+ "format": "prettier --write .",
54
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "https://github.com/AdriAir/iFrameConnector.git"
59
+ },
60
+ "bugs": {
61
+ "url": "https://github.com/AdriAir/iFrameConnector/issues"
62
+ },
63
+ "homepage": "https://github.com/AdriAir/iFrameConnector#readme",
64
+ "devDependencies": {
65
+ "@eslint/js": "^10.0.1",
66
+ "@vitest/coverage-v8": "^4.0.18",
67
+ "@vitest/ui": "^4.0.18",
68
+ "cpx": "^1.5.0",
69
+ "eslint": "^10.0.1",
70
+ "eslint-config-prettier": "^10.1.8",
71
+ "happy-dom": "^20.5.0",
72
+ "prettier": "^3.8.1",
73
+ "rimraf": "^6.1.2",
74
+ "serve": "^14.2.5",
75
+ "tsup": "^8.5.1",
76
+ "typescript": "^5.9.3",
77
+ "typescript-eslint": "^8.56.0",
78
+ "vitest": "^4.0.18"
79
+ }
80
+ }