@cp949/iframecall 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cp949
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,261 @@
1
+ # @cp949/iframecall
2
+
3
+ 타입 안전한 host ↔ iframe `postMessage` 호출 라이브러리. host에서 iframe의 메서드를 함수처럼 호출하고, iframe에서 host로 알림(notification)을 보낼 수 있다. React 18/19 모두 지원한다.
4
+
5
+ - 양방향 통신: host → iframe RPC 호출, iframe → host 알림
6
+ - 타입 안전: 커맨드 시그니처와 알림 페이로드를 제네릭으로 고정
7
+ - React 훅 제공: `useIframeCallController` (host), `useIframeCallRunner` (iframe)
8
+ - ESM 전용, 브라우저 환경 전용
9
+ - origin 화이트리스트와 ready 핸드셰이크 내장
10
+
11
+ ## 설치
12
+
13
+ ```sh
14
+ pnpm add @cp949/iframecall
15
+ # 또는
16
+ npm install @cp949/iframecall
17
+ # 또는
18
+ yarn add @cp949/iframecall
19
+ ```
20
+
21
+ `react`, `@types/react`는 peer dependency이다. 프로젝트에 이미 설치되어 있어야 한다.
22
+
23
+ | peer | 지원 버전 |
24
+ |------|-----------|
25
+ | `react` | `^18.0.0 \|\| ^19.0.0` |
26
+ | `@types/react` | `^18.0.0 \|\| ^19.0.0` |
27
+
28
+ ## 진입점
29
+
30
+ | import | 용도 |
31
+ |--------|------|
32
+ | `@cp949/iframecall/host` | 부모 페이지(host)에서 iframe을 제어할 때 |
33
+ | `@cp949/iframecall/iframe` | 임베드된 페이지(iframe)에서 host의 호출을 받을 때 |
34
+
35
+ 호스트와 iframe은 서로 다른 origin에서 실행되며, 각 진입점은 그쪽에서만 필요한 API와 타입만 노출한다.
36
+
37
+ ## 빠른 시작
38
+
39
+ ### 1. 공유 타입 정의
40
+
41
+ host와 iframe 양쪽에서 동일하게 사용할 커맨드와 알림 타입을 정의한다.
42
+
43
+ ```ts
44
+ type DemoCommands = {
45
+ greet(name: string): Promise<string>;
46
+ add(a: number, b: number): Promise<number>;
47
+ delay(ms: number): Promise<void>;
48
+ };
49
+
50
+ type DemoEvents = {
51
+ "status-changed": string;
52
+ };
53
+ ```
54
+
55
+ > 별도 공유 패키지 없이 양쪽에 같은 타입을 두는 패턴을 권장한다. 모노레포라면 공통 패키지로 빼도 된다.
56
+
57
+ ### 2. host 측 (부모 페이지)
58
+
59
+ ```tsx
60
+ "use client";
61
+
62
+ import { useIframeCallController } from "@cp949/iframecall/host";
63
+ import { useEffect } from "react";
64
+
65
+ const IFRAME_ORIGIN = "https://iframe.example.com";
66
+
67
+ export function HostPage() {
68
+ const { iframeRef, controller, status } = useIframeCallController<
69
+ DemoCommands,
70
+ DemoEvents
71
+ >({
72
+ targetOrigin: IFRAME_ORIGIN,
73
+ allowedOrigins: [IFRAME_ORIGIN],
74
+ });
75
+
76
+ useEffect(() => {
77
+ if (!controller) return;
78
+ return controller.onNotificationFromIframe("status-changed", (payload) => {
79
+ console.log("iframe status:", payload);
80
+ });
81
+ }, [controller]);
82
+
83
+ const handleGreet = async () => {
84
+ if (!controller) return;
85
+ const result = await controller.call("greet", ["World"]);
86
+ console.log(result); // "Hello, World!"
87
+ };
88
+
89
+ return (
90
+ <div>
91
+ <p>status: {status}</p>
92
+ <button type="button" onClick={handleGreet} disabled={status !== "ready"}>
93
+ Greet
94
+ </button>
95
+ <iframe
96
+ ref={iframeRef}
97
+ src={IFRAME_ORIGIN}
98
+ title="iframe demo"
99
+ />
100
+ </div>
101
+ );
102
+ }
103
+ ```
104
+
105
+ ### 3. iframe 측 (임베드된 페이지)
106
+
107
+ 커맨드 구현은 클래스로 정의한다. 생성자는 `iframeHelper`를 인자로 받으며, 메서드 이름이 곧 커맨드 이름이 된다.
108
+
109
+ ```tsx
110
+ "use client";
111
+
112
+ import {
113
+ type IframeHelper,
114
+ useIframeCallRunner,
115
+ } from "@cp949/iframecall/iframe";
116
+ import { useEffect } from "react";
117
+
118
+ const HOST_ORIGIN = "https://host.example.com";
119
+
120
+ class DemoCommandsImpl {
121
+ constructor(private iframeHelper: IframeHelper<DemoEvents>) {}
122
+
123
+ async greet(name: string): Promise<string> {
124
+ this.iframeHelper.sendNotificationToHost("status-changed", "processing");
125
+ const result = `Hello, ${name}!`;
126
+ this.iframeHelper.sendNotificationToHost("status-changed", "idle");
127
+ return result;
128
+ }
129
+
130
+ async add(a: number, b: number): Promise<number> {
131
+ return a + b;
132
+ }
133
+
134
+ async delay(ms: number): Promise<void> {
135
+ await new Promise((resolve) => setTimeout(resolve, ms));
136
+ }
137
+ }
138
+
139
+ export function IframePage() {
140
+ const { iframeHelper, isActive } = useIframeCallRunner<
141
+ DemoCommands,
142
+ DemoEvents
143
+ >({
144
+ targetOrigin: HOST_ORIGIN,
145
+ allowedOrigins: [HOST_ORIGIN],
146
+ Commands: DemoCommandsImpl,
147
+ });
148
+
149
+ useEffect(() => {
150
+ if (!iframeHelper) return;
151
+ iframeHelper.sendNotificationToHost("status-changed", "ready");
152
+ iframeHelper.sendReadyToHost();
153
+ }, [iframeHelper]);
154
+
155
+ return <p>{isActive ? "active" : "initializing"}</p>;
156
+ }
157
+ ```
158
+
159
+ ## 동작 흐름
160
+
161
+ ```text
162
+ host iframe
163
+ │ │
164
+ │ <iframe src="..."> │
165
+ │──────────────────────────────────────▶│ mount
166
+ │ │ sendReadyToHost()
167
+ │ ◀── ready ───────────────────────────│
168
+ │ controller.status = "ready" │
169
+ │ │
170
+ │ controller.call("greet", ["World"]) │
171
+ │ ── request ─────────────────────────▶│ DemoCommandsImpl.greet("World")
172
+ │ ◀── response: "Hello, World!" ───────│
173
+ │ │
174
+ │ ◀── notify "status-changed" ─────────│ sendNotificationToHost(...)
175
+ ```
176
+
177
+ - iframe이 마운트되면 `sendReadyToHost()`로 준비 신호를 보낸다.
178
+ - host의 `controller.call`은 ready 시점까지 대기한 뒤 전송된다 (기본 `ReadyPolicy: "wait"`).
179
+ - 응답은 Promise로 돌아오며, iframe 측 메서드가 throw하면 host 쪽 Promise는 reject된다.
180
+ - iframe → host 단방향 알림은 `sendNotificationToHost`로 보내고, host 쪽에서 `controller.onNotificationFromIframe`으로 받는다.
181
+
182
+ ## API 개요
183
+
184
+ ### host 진입점
185
+
186
+ | export | 종류 | 설명 |
187
+ |--------|------|------|
188
+ | `useIframeCallController` | hook | host용 React 훅. `iframeRef`, `controller`, `status`를 반환한다. |
189
+ | `createIframeCallController` | factory | 훅 없이 컨트롤러를 직접 만들 때 사용 |
190
+ | `createIframeWindowTransport` | factory | 커스텀 트랜스포트 구성용 |
191
+ | `consoleDebugLogger` | util | 디버그 이벤트를 콘솔에 출력하는 로거 |
192
+
193
+ 훅이 반환하는 `controller`의 주요 멤버:
194
+
195
+ - `controller.call(command, args, options?)` — iframe의 커맨드 호출, 결과를 Promise로 반환
196
+ - `controller.onNotificationFromIframe(event, handler)` — iframe이 보내는 알림 구독, unsubscribe 함수 반환
197
+ - `controller.ready` — iframe ready 신호 대기용 Promise
198
+ - `controller.terminated` — 종료 사유를 노출하는 Promise (정상 dispose면 `null`)
199
+ - `controller.debug.subscribe(handler)` — 송수신 이벤트 디버그 스트림 구독
200
+ - `controller.dispose(reason?)` — 명시적 정리 (훅을 쓰면 자동 호출됨)
201
+
202
+ ### iframe 진입점
203
+
204
+ | export | 종류 | 설명 |
205
+ |--------|------|------|
206
+ | `useIframeCallRunner` | hook | iframe용 React 훅. `iframeHelper`, `isActive`를 반환한다. |
207
+ | `createIframeCallRunner` | factory | 훅 없이 러너를 직접 만들 때 사용 |
208
+ | `createParentWindowTransport` | factory | 커스텀 트랜스포트 구성용 |
209
+
210
+ `iframeHelper`의 주요 멤버:
211
+
212
+ - `iframeHelper.sendNotificationToHost(event, payload)` — host로 알림 전송 (lifecycle 예약 이름 `ready`/`terminated`는 제외)
213
+ - `iframeHelper.sendReadyToHost()` — host에 ready 신호 전송
214
+ - `iframeHelper.debug.subscribe(handler)` — 디버그 스트림 구독
215
+
216
+ > 알림은 iframe → host 단방향이다. host → iframe 알림은 라이브러리 외부에서 `postMessage`로 직접 처리하거나, host에서 커맨드를 호출해 처리한다.
217
+
218
+ ### 공통 타입
219
+
220
+ `CommandMap`, `IframeCallController`, `IframeCallRunnerHandle`, `IframeCallTransport`, `ReadyPolicy`, `SerializedIframeCallError` 등 핵심 타입은 `host`/`iframe` 양쪽에서 모두 export된다.
221
+
222
+ ## 보안: origin 검증
223
+
224
+ `targetOrigin`과 `allowedOrigins`는 반드시 명시적으로 지정한다. 와일드카드(`*`)는 사용하지 않는다.
225
+
226
+ | 옵션 | 의미 |
227
+ |------|------|
228
+ | `targetOrigin` | `postMessage` 전송 시 사용할 대상 origin |
229
+ | `allowedOrigins` | 수신 시 허용할 origin 화이트리스트 (배열) |
230
+
231
+ 수신 메시지의 `event.origin`이 화이트리스트에 없으면 무시된다.
232
+
233
+ ## 디버깅
234
+
235
+ 훅 옵션에 `debugLog: true`를 주면 송수신 이벤트가 콘솔에 출력된다. 또는 `consoleDebugLogger`를 직접 전달할 수도 있다.
236
+
237
+ ```ts
238
+ useIframeCallController({
239
+ targetOrigin: IFRAME_ORIGIN,
240
+ allowedOrigins: [IFRAME_ORIGIN],
241
+ debugLog: true,
242
+ });
243
+ ```
244
+
245
+ 세밀한 제어가 필요하면 `controller.debug.subscribe` / `iframeHelper.debug.subscribe`로 이벤트 스트림을 직접 구독한다.
246
+
247
+ ## 데모
248
+
249
+ 레포에는 동일 시나리오를 React 18/19로 각각 구현한 4개 데모 앱이 있다.
250
+
251
+ ```sh
252
+ # React 19 한 쌍 (host: 3300, iframe: 3301)
253
+ pnpm dev:r19
254
+
255
+ # React 18 한 쌍 (host: 3302, iframe: 3303)
256
+ pnpm dev:r18
257
+ ```
258
+
259
+ ## 라이선스
260
+
261
+ MIT
@@ -0,0 +1,175 @@
1
+ // src/core/errors.ts
2
+ function createIframeCallError(code, message, options = {}) {
3
+ return {
4
+ code,
5
+ message,
6
+ ...options.command === void 0 ? {} : { command: options.command },
7
+ ...options.details === void 0 ? {} : { details: options.details },
8
+ ...options.cause === void 0 ? {} : { cause: options.cause }
9
+ };
10
+ }
11
+ function serializeIframeCallError(value, command) {
12
+ if (isSerializedIframeCallError(value)) {
13
+ return {
14
+ ...value,
15
+ ...value.command === void 0 && command !== void 0 ? { command } : {}
16
+ };
17
+ }
18
+ if (value instanceof Error) {
19
+ return createIframeCallError("command_failed", value.message, {
20
+ command,
21
+ details: { name: value.name }
22
+ });
23
+ }
24
+ return createIframeCallError("command_failed", String(value), {
25
+ command,
26
+ details: value
27
+ });
28
+ }
29
+ function isSerializedIframeCallError(value) {
30
+ return typeof value === "object" && value !== null && "code" in value && "message" in value && typeof value.code === "string" && typeof value.message === "string";
31
+ }
32
+
33
+ // src/core/messages.ts
34
+ function createIframeCallRequest(id, cmd, args) {
35
+ return {
36
+ protocol: "iframecall",
37
+ version: 1,
38
+ id,
39
+ cmd,
40
+ args
41
+ };
42
+ }
43
+ function createIframeCallSuccessResponse(id, value) {
44
+ return {
45
+ protocol: "iframecall",
46
+ version: 1,
47
+ id,
48
+ ok: true,
49
+ value
50
+ };
51
+ }
52
+ function createIframeCallErrorResponse(id, error) {
53
+ return {
54
+ protocol: "iframecall",
55
+ version: 1,
56
+ id,
57
+ ok: false,
58
+ error
59
+ };
60
+ }
61
+ function createIframeCallNotify(event, payload) {
62
+ return {
63
+ protocol: "iframecall",
64
+ version: 1,
65
+ event,
66
+ payload
67
+ };
68
+ }
69
+ function parseIframeCallMessage(value) {
70
+ if (!isRecord(value)) {
71
+ return null;
72
+ }
73
+ if (value.protocol !== "iframecall" || value.version !== 1) {
74
+ return null;
75
+ }
76
+ if (isIframeCallRequest(value)) {
77
+ return { type: "request", message: value };
78
+ }
79
+ if (isIframeCallResponse(value)) {
80
+ return { type: "response", message: value };
81
+ }
82
+ if (isIframeCallNotify(value)) {
83
+ return { type: "notify", message: value };
84
+ }
85
+ return null;
86
+ }
87
+ function isIframeCallRequest(value) {
88
+ return typeof value.id === "string" && typeof value.cmd === "string" && Array.isArray(value.args) && !("ok" in value) && !("event" in value) && !("payload" in value);
89
+ }
90
+ function isIframeCallResponse(value) {
91
+ if (typeof value.id !== "string" || !("ok" in value)) {
92
+ return false;
93
+ }
94
+ if (value.ok === true) {
95
+ return "value" in value && !("cmd" in value) && !("event" in value) && !("args" in value);
96
+ }
97
+ if (value.ok === false) {
98
+ return isSerializedIframeCallError2(value.error) && !("cmd" in value) && !("event" in value) && !("args" in value) && !("value" in value);
99
+ }
100
+ return false;
101
+ }
102
+ function isIframeCallNotify(value) {
103
+ return typeof value.event === "string" && !("id" in value) && !("cmd" in value) && !("ok" in value) && "payload" in value;
104
+ }
105
+ function isSerializedIframeCallError2(value) {
106
+ return isRecord(value) && typeof value.code === "string" && typeof value.message === "string";
107
+ }
108
+ function isRecord(value) {
109
+ return typeof value === "object" && value !== null;
110
+ }
111
+
112
+ // src/core/transport.ts
113
+ function createIframeWindowTransport(iframe) {
114
+ return {
115
+ expectedSource: iframe.contentWindow ?? void 0,
116
+ post(message, targetOrigin, transfer) {
117
+ iframe.contentWindow?.postMessage(
118
+ message,
119
+ targetOrigin,
120
+ transfer
121
+ );
122
+ },
123
+ subscribe(handler) {
124
+ const listener = (event) => {
125
+ handler({
126
+ data: event.data,
127
+ origin: event.origin,
128
+ source: event.source
129
+ });
130
+ };
131
+ window.addEventListener("message", listener);
132
+ return () => {
133
+ window.removeEventListener("message", listener);
134
+ };
135
+ }
136
+ };
137
+ }
138
+ function createParentWindowTransport() {
139
+ return {
140
+ expectedSource: window.parent,
141
+ post(message, targetOrigin, transfer) {
142
+ window.parent.postMessage(
143
+ message,
144
+ targetOrigin,
145
+ transfer
146
+ );
147
+ },
148
+ subscribe(handler) {
149
+ const listener = (event) => {
150
+ handler({
151
+ data: event.data,
152
+ origin: event.origin,
153
+ source: event.source
154
+ });
155
+ };
156
+ window.addEventListener("message", listener);
157
+ return () => {
158
+ window.removeEventListener("message", listener);
159
+ };
160
+ }
161
+ };
162
+ }
163
+
164
+ export {
165
+ createIframeCallError,
166
+ serializeIframeCallError,
167
+ isSerializedIframeCallError,
168
+ createIframeCallRequest,
169
+ createIframeCallSuccessResponse,
170
+ createIframeCallErrorResponse,
171
+ createIframeCallNotify,
172
+ parseIframeCallMessage,
173
+ createIframeWindowTransport,
174
+ createParentWindowTransport
175
+ };
package/dist/host.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { H as HostDebugEvent, C as CommandMap, I as IframeCallControllerOptions, a as IframeCallController, S as SerializedIframeCallError } from './messages-Cg4n93bf.js';
2
+ export { b as CommandArgs, c as CommandHandler, d as CommandResult, e as CommandRunner, f as IframeCallCallOptions, g as IframeCallLogger, h as IframeCallNotify, i as IframeCallRequest, j as IframeCallResponse, k as IframeCallTransferable, l as IframeCallTransport, m as IframeCallTransportEvent, N as NotifyHandler, P as ParsedIframeCallMessage, R as ReadyPolicy, n as ReservedNotificationName, o as createIframeCallError, p as createIframeCallErrorResponse, q as createIframeCallNotify, r as createIframeCallRequest, s as createIframeCallSuccessResponse, t as createIframeWindowTransport, u as isSerializedIframeCallError, v as parseIframeCallMessage, w as serializeIframeCallError } from './messages-Cg4n93bf.js';
3
+
4
+ /** consoleDebugLogger 옵션. prefix를 명시하지 않으면 [iframecall:host]가 기본값이다. */
5
+ type ConsoleDebugLoggerOptions = {
6
+ readonly prefix?: string;
7
+ };
8
+ /**
9
+ * host controller.debug.subscribe에 그대로 넘길 수 있는 console.debug 출력 함수.
10
+ * event type별로 핵심 식별자(command/event/reason)를 첫 인자에 포함하고,
11
+ * raw payload(args/value/error/payload)를 두 번째 인자로 전달해 DevTools에서 펼쳐 볼 수 있게 한다.
12
+ */
13
+ declare function consoleDebugLogger(options?: ConsoleDebugLoggerOptions): (event: HostDebugEvent) => void;
14
+
15
+ /**
16
+ * iframecall host controller를 생성한다.
17
+ * 옵션을 정규화하고 transport를 구독해 ready/notify/response 이벤트를 dispatch하는 controller 객체를 돌려준다.
18
+ * 두 번째 generic은 iframe -> host notify의 payload 타입 추론에 사용한다.
19
+ */
20
+ declare function createIframeCallController<TCommands extends CommandMap<TCommands>, TNotificationsFromIframe = Record<string, unknown>>(options: IframeCallControllerOptions<TCommands>): IframeCallController<TCommands, TNotificationsFromIframe>;
21
+
22
+ /** controller가 거치는 lifecycle 단계. host shell이 직접 화면에 노출하기 좋은 단계로 좁힌다. */
23
+ type IframeCallControllerStatus = "pending" | "ready" | "failed" | "terminated";
24
+ /** debugLog 옵션. true면 자동 구독, prefix override 가능. false/undefined면 미구독. */
25
+ type UseIframeCallControllerDebugLog = boolean | {
26
+ readonly prefix?: string;
27
+ };
28
+ /**
29
+ * useIframeCallController에 전달하는 옵션.
30
+ * iframe element는 callback ref로 받으므로 controller 생성 옵션에서 제외한다.
31
+ */
32
+ type UseIframeCallControllerOptions<TCommands extends CommandMap<TCommands>> = {
33
+ readonly targetOrigin: string;
34
+ readonly debugLog?: UseIframeCallControllerDebugLog;
35
+ } & Pick<IframeCallControllerOptions<TCommands>, "allowedOrigins" | "defaultTimeoutMs" | "generateId" | "logger" | "readyPolicy" | "readyQueueLimit" | "readyTimeoutMs" | "transport">;
36
+ /** useIframeCallController가 반환하는 handle. mount 전에는 controller가 null이다. */
37
+ type UseIframeCallControllerResult<TCommands extends CommandMap<TCommands>, TNotificationsFromIframe = Record<string, unknown>> = {
38
+ readonly iframeRef: (node: HTMLIFrameElement | null) => void;
39
+ readonly controller: IframeCallController<TCommands, TNotificationsFromIframe> | null;
40
+ readonly status: IframeCallControllerStatus;
41
+ readonly terminationError: SerializedIframeCallError | null;
42
+ readonly readyError: unknown;
43
+ };
44
+ /**
45
+ * iframecall host controller를 React lifecycle에 묶는 hook.
46
+ * mount 시 한 번 controller를 생성하고, unmount 시 dispose한다.
47
+ * 옵션 reference 변경은 무시되므로 호출처는 옵션을 매 render마다 새로 생성해도 안전하다.
48
+ * 새 controller가 필요한 경우 부모 컴포넌트에서 key prop을 변경해 hook 인스턴스를 리마운트한다.
49
+ */
50
+ declare function useIframeCallController<TCommands extends CommandMap<TCommands>, TNotificationsFromIframe = Record<string, unknown>>(options: UseIframeCallControllerOptions<TCommands>): UseIframeCallControllerResult<TCommands, TNotificationsFromIframe>;
51
+
52
+ export { CommandMap, HostDebugEvent, IframeCallController, IframeCallControllerOptions, type IframeCallControllerStatus, SerializedIframeCallError, type UseIframeCallControllerDebugLog, type UseIframeCallControllerOptions, type UseIframeCallControllerResult, consoleDebugLogger, createIframeCallController, useIframeCallController };