@cp949/iframecall 0.2.1 → 0.3.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/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@
4
4
 
5
5
  형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)을 따르며, 버전 관리는 [Semantic Versioning](https://semver.org/spec/v2.0.0.html)을 준수합니다. 0.x 버전대에서는 호환성이 깨지는 변경도 minor 버전 증가로 처리합니다.
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-09-23
10
+
11
+ ### Added
12
+
13
+ - host controller와 `useIframeCallController`에 `opaqueOrigin: true` 옵션을 추가했습니다. `sandbox`로 격리된 opaque origin(`"null"`) iframe과 통신합니다. 송신은 `"*"`, 수신은 origin `"null"`과 `contentWindow` source가 모두 일치할 때만 받습니다.
14
+ - `IframeCallControllerOriginOptions` 타입을 export합니다.
15
+ - ready 재요청 handshake를 추가했습니다. host controller는 구독 직후 `host:ready-query` notify를 보내고, 이미 ready를 보낸 runner는 `requested: true` ready로 다시 응답합니다. iframe이 host보다 먼저 로드되어 첫 ready가 유실되던 문제(controller가 `pending`에 머묾)를 해결합니다. 이전 버전 host/runner와 섞여도 동작은 기존과 같습니다(`protocolVersion` 1 유지).
16
+
17
+ ### Changed
18
+
19
+ - host controller가 생성 시 `host:ready-query` notify를 한 번 전송합니다. `requested: true`가 붙은 중복 ready는 경고 없이 무시합니다.
20
+ - `opaqueOrigin` 모드에서 `targetOrigin`/`allowedOrigins`를 함께 지정하거나 transport에 `expectedSource`가 없으면 `invalid_origin`을 던집니다. 기존 모드의 wildcard 거부 동작은 그대로입니다.
21
+
7
22
  ## [0.2.1] - 2026-08-25
8
23
 
9
24
  ### Added
package/README.md CHANGED
@@ -32,7 +32,7 @@ yarn add @cp949/iframecall
32
32
  | `@cp949/iframecall/host` | 부모 페이지(host)에서 iframe을 제어할 때 |
33
33
  | `@cp949/iframecall/iframe` | 임베드된 페이지(iframe)에서 host의 호출을 받을 때 |
34
34
 
35
- 호스트와 iframe은 서로 다른 origin에서 실행되며, 각 진입점은 그쪽에서만 필요한 API와 타입만 노출한다.
35
+ host와 iframe은 서로 다른 origin에서도 동작하며, 각 진입점은 그쪽에서만 필요한 API와 타입만 노출한다.
36
36
 
37
37
  ## 빠른 시작
38
38
 
@@ -212,6 +212,8 @@ export function IframePage() {
212
212
  ```text
213
213
  host iframe
214
214
  │ │
215
+ │ controller 생성 (listener 등록) │
216
+ │ ── notify host:ready-query ─────────▶│ (ready 전이면 무시)
215
217
  │ <iframe src="..."> │
216
218
  │──────────────────────────────────────▶│ mount
217
219
  │ │ commands._sendLifecycleReady() → sendLifecycleReady()
@@ -226,7 +228,9 @@ host iframe
226
228
  ```
227
229
 
228
230
  - iframe이 마운트되면 `commands._sendLifecycleReady()`가 `sendLifecycleReady()`를 통해 transport ready 신호를 보낸다.
229
- - host의 `controller.invoke`는 ready 시점까지 대기한 뒤 전송된다.
231
+ - ready 전에 호출한 `controller.invoke`는 기본값(`readyPolicy: "queue"`)에서 ready까지 대기한 뒤 전송된다. `readyPolicy: "reject"`면 즉시 거부된다.
232
+ - **ready 재요청(ready-query)**: controller는 `message` listener를 등록한 직후 iframe에 `host:ready-query` notify를 한 번 보낸다. 이미 `sendLifecycleReady()`를 호출한 runner는 ready(`requested: true`)로 다시 응답한다. 그래서 SSR된 `<iframe src>`처럼 iframe이 host hydration보다 먼저 로드되어 첫 ready가 유실돼도 controller가 ready에 도달한다. 앱이 아직 ready 전이면 runner는 query를 무시하고, 이후 앱이 보내는 ready로 연결된다.
233
+ - host와 runner가 모두 이 기능을 포함한 버전일 때만 복구된다. 이전 버전 runner는 query를 무시하므로, 그 경우에는 iframe `src`를 controller가 생긴 뒤 설정한다(`src={controller ? IFRAME_URL : undefined}`). 같은 요소의 `src` 변경은 `contentWindow` identity를 유지하므로 source 검사에 영향이 없다.
230
234
  - 응답은 Promise로 돌아오며, iframe 측 메서드가 throw하면 host 쪽 Promise는 reject된다.
231
235
  - iframe → host 단방향 알림은 `sendNotificationToHost`로 보내고, host 쪽에서 `controller.onNotificationFromIframe`으로 받는다.
232
236
  - **라이프사이클 채널과 도메인 채널은 책임이 다르다.** `ready`/`terminated`는 transport 신호 전용이고, 도메인 알림(`status-changed` 등)에는 `"ready"` 같은 lifecycle 의미를 담지 않는다.
@@ -236,12 +240,12 @@ host iframe
236
240
 
237
241
  ### host 진입점
238
242
 
239
- | export | 종류 | 설명 |
240
- | ----------------------------- | ------- | ---------------------------------------------------------------- |
241
- | `useIframeCallController` | hook | host용 React 훅. `iframeRef`, `controller`, `status`를 반환한다. |
242
- | `createIframeCallController` | factory | 훅 없이 컨트롤러를 직접 만들 때 사용 |
243
- | `createIframeWindowTransport` | factory | 커스텀 트랜스포트 구성용 |
244
- | `consoleDebugLogger` | util | 디버그 이벤트를 콘솔에 출력하는 로거 |
243
+ | export | 종류 | 설명 |
244
+ | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------- |
245
+ | `useIframeCallController` | hook | host용 React 훅. `iframeRef`, `controller`, `status`, `terminationError`, `readyError`를 반환한다. |
246
+ | `createIframeCallController` | factory | 훅 없이 컨트롤러를 직접 만들 때 사용 |
247
+ | `createIframeWindowTransport` | factory | 커스텀 트랜스포트 구성용 |
248
+ | `consoleDebugLogger` | util | 디버그 이벤트를 콘솔에 출력하는 로거 |
245
249
 
246
250
  훅이 반환하는 `controller`의 주요 멤버:
247
251
 
@@ -275,26 +279,69 @@ host iframe
275
279
 
276
280
  `onStatusChange`처럼 prefix가 없으면서 host에서 호출하면 안 되는 메서드는 반드시 `_` prefix를 붙여야 한다. 그렇지 않으면 host가 `controller.invoke("onStatusChange", [...])`로 직접 호출할 수 있다.
277
281
 
278
- > 알림은 iframe → host 단방향이다. host → iframe 알림은 라이브러리 외부에서 `postMessage`로 직접 처리하거나, host에서 커맨드를 호출해 처리한다.
282
+ > 도메인 알림은 iframe → host 단방향이다(라이브러리 내부 lifecycle 메시지 `host:ready-query`는 예외). host → iframe 도메인 알림은 라이브러리 외부에서 `postMessage`로 직접 처리하거나, host에서 커맨드를 호출해 처리한다.
279
283
 
280
284
  ### 공통 타입
281
285
 
282
- `CommandMap`, `IframeCallController`, `IframeCallRunnerHandle`, `IframeCallTransport`, `ReadyPolicy`, `SerializedIframeCallError` 등 핵심 타입은 `host`/`iframe` 양쪽에서 모두 export된다.
286
+ `CommandMap`, `IframeCallTransport`, `ReadyPolicy`, `SerializedIframeCallError` 등 공통 타입은 `host`/`iframe` 양쪽에서 export된다. 역할 전용 타입은 한쪽에서만 export된다: `IframeCallController`는 `host`, `IframeCallRunnerHandle`은 `iframe`.
283
287
 
284
288
  ## 보안: origin 검증
285
289
 
286
- `targetOrigin`과 `allowedOrigins`는 반드시 명시적으로 지정한다. 와일드카드(`*`)는 사용하지 않는다.
290
+ `targetOrigin`은 명시적인 origin으로 지정한다. 와일드카드(`*`)는 사용하지 않는다. `allowedOrigins`를 생략하면 `[targetOrigin]`을 쓴다. opaque origin 모드에서는 둘 다 지정하지 않는다.
287
291
 
288
292
  | 옵션 | 의미 |
289
293
  | ---------------- | ----------------------------------------- |
290
294
  | `targetOrigin` | `postMessage` 전송 시 사용할 대상 origin |
291
295
  | `allowedOrigins` | 수신 시 허용할 origin 화이트리스트 (배열) |
292
296
 
293
- 수신 메시지의 `event.origin`이 화이트리스트에 없으면 무시된다.
297
+ 수신 메시지의 `event.origin`이 화이트리스트에 없으면 무시된다. host의 기본 transport는 `event.source`가 대상 iframe의 `contentWindow`인지도 함께 검사한다. 단, 일반 모드에서 controller 생성 시점에 iframe이 문서에 붙지 않아 `contentWindow`가 없으면 source 검사를 생략한다. controller는 iframe을 문서에 붙인 뒤 만든다(훅은 ref 연결 후 생성하므로 해당 없음).
298
+
299
+ `targetOrigin`에 `""`, `"*"`, `"null"`을 넘기면 `invalid_origin` 에러를 던진다. origin이 `"null"`인 iframe은 아래 opaque origin 모드를 사용한다.
300
+
301
+ ### opaque origin iframe (`sandbox`)
302
+
303
+ `sandbox="allow-scripts"`처럼 `allow-same-origin` 없이 띄운 iframe은 origin이 `"null"`이라 명시적인 `targetOrigin`으로 메시지를 보낼 수 없다. host에서 `opaqueOrigin: true`로 opt-in한다.
304
+
305
+ ```tsx
306
+ const { iframeRef, controller, status } = useIframeCallController<AppCommands>({
307
+ opaqueOrigin: true,
308
+ });
309
+
310
+ return <iframe ref={iframeRef} sandbox="allow-scripts" srcDoc={runnerHtml} />;
311
+ ```
312
+
313
+ > **iframe 안에서 신뢰하지 않는 코드가 실행되면 채널 전체를 신뢰하지 않는 상대로 취급한다.** 아래 "보안 근거"를 먼저 읽는다.
314
+
315
+ 이 모드의 동작:
316
+
317
+ - 송신: 내부적으로 `targetOrigin="*"`로 보낸다. 대상은 해당 iframe의 `contentWindow`뿐이다.
318
+ - 수신: `event.origin === "null"` **그리고** `event.source === contentWindow`일 때만 받는다.
319
+ - 생성 시 거부(`invalid_origin`):
320
+ - `targetOrigin` 또는 `allowedOrigins`를 함께 지정한 경우. 타입에서도 거부한다.
321
+ - iframe이 문서에 붙지 않아 `contentWindow`가 없는 경우, 또는 커스텀 `transport`에 `expectedSource`가 없는 경우.
322
+
323
+ 보안 근거:
324
+
325
+ - **source 비교가 필수인 이유**: origin `"null"`은 다른 사이트의 모든 sandboxed frame이 공유하는 값이다. origin만으로는 대상 iframe과 다른 opaque frame을 구별할 수 없다. 그래서 이 모드는 source를 비교할 수 없는 설정을 생성 시점에 거부한다.
326
+ - **source 검사는 iframe 요소를 인증할 뿐 문서를 인증하지 않는다**: `sandbox="allow-scripts"`는 iframe 문서가 자기 자신을 navigation하는 것(`location.href = ...`)을 막지 않는다. 이동한 문서도 sandbox flag를 이어받아 origin이 `"null"`이고, 같은 요소라 `contentWindow`도 같다. 따라서 그 문서는 host의 origin·source 검사를 모두 통과하고, `"*"`로 보낸 command와 인자를 받으며, 응답과 notify를 보낼 수 있다. (headless Chromium에서 확인)
327
+ - **수용 조건**: 위 위험은 iframe 안에서 신뢰하지 않는 코드가 돌지 않을 때만 수용할 수 있다. 이 경우 `src`/`srcdoc`는 host가 통제하므로 iframe이 모르는 문서로 이동하지 않는다.
328
+ - **신뢰하지 않는 코드를 실행하는 iframe**(사용자 코드 실행 샌드박스 등)에서는 채널 전체를 신뢰하지 않는 상대로 취급한다. command 인자에 비밀이나 권한 토큰을 넣지 않고, 응답과 notify는 host에서 검증한다. 이 경우 iframe 안의 코드는 navigation 없이도 채널을 이미 제어할 수 있다. navigation은 그 제어를 iframe 밖의 문서로 넘기는 경로를 하나 더 만든다.
329
+
330
+ iframe 쪽 runner는 바꿀 필요가 없다. parent(host)는 일반 origin이므로 runner는 지금처럼 `targetOrigin: HOST_ORIGIN`을 명시한다.
331
+
332
+ ### iframe 요소 교체
333
+
334
+ controller는 iframe 요소 하나에 묶인다. 같은 요소의 `src` 변경은 `contentWindow` identity를 유지하지만, 요소 자체를 새로 만들면 `contentWindow`가 달라진다. 훅은 요소 교체를 감지하지 않으므로, 요소를 교체할 때는 훅을 소유한 컴포넌트를 `key`로 리마운트한다.
335
+
336
+ ```tsx
337
+ <SandboxFrame key={runId} />
338
+ ```
339
+
340
+ 리마운트하면 이전 controller가 dispose되어 대기 중인 요청이 `terminated`로 reject되고, 새 controller는 이전 window에서 늦게 도착한 메시지를 source 검사로 버린다.
294
341
 
295
342
  ## 디버깅
296
343
 
297
- 훅 옵션에 `debugLog: true`를 주면 송수신 이벤트가 콘솔에 출력된다. 또는 `consoleDebugLogger`를 직접 전달할 수도 있다.
344
+ 훅 옵션에 `debugLog: true`(또는 `{ prefix }`)를 주면 송수신 이벤트가 콘솔에 출력된다. 없이 쓸 때는 `controller.debug.subscribe(consoleDebugLogger())`로 같은 출력을 붙인다.
298
345
 
299
346
  ```ts
300
347
  useIframeCallController({
@@ -318,6 +365,14 @@ pnpm dev:r19
318
365
  pnpm dev:r18
319
366
  ```
320
367
 
368
+ opaque origin 모드 데모는 `http://localhost:3300/opaque`다. iframe-r19 앱을 `sandbox="allow-scripts"`로 띄운다. Next.js dev server는 origin `"null"` 문서의 `/_next` 리소스 요청을 cross-origin으로 차단하므로 iframe 앱은 production 모드로 실행한다.
369
+
370
+ ```sh
371
+ pnpm build
372
+ pnpm --filter iframe-r19 exec next start --port 3301
373
+ pnpm --filter host-r19 dev
374
+ ```
375
+
321
376
  ## 라이선스
322
377
 
323
378
  MIT
@@ -31,6 +31,7 @@ function isSerializedIframeCallError(value) {
31
31
  }
32
32
 
33
33
  // src/core/messages.ts
34
+ var READY_QUERY_EVENT = "host:ready-query";
34
35
  function createIframeCallRequest(id, cmd, args) {
35
36
  return {
36
37
  protocol: "iframecall",
@@ -163,15 +164,33 @@ function createParentWindowTransport() {
163
164
  };
164
165
  }
165
166
 
167
+ // src/core/inboundValidation.ts
168
+ function validateInbound(event, policy) {
169
+ if (!policy.allowedOrigins.has(event.origin)) {
170
+ return { accepted: false, reason: "origin" };
171
+ }
172
+ if (policy.expectedSource === void 0) {
173
+ if (policy.requireSource === true) {
174
+ return { accepted: false, reason: "source" };
175
+ }
176
+ } else if (event.source !== policy.expectedSource) {
177
+ return { accepted: false, reason: "source" };
178
+ }
179
+ const message = parseIframeCallMessage(event.data);
180
+ return message === null ? { accepted: false, reason: "message" } : { accepted: true, message };
181
+ }
182
+
166
183
  export {
167
184
  createIframeCallError,
168
185
  serializeIframeCallError,
169
186
  isSerializedIframeCallError,
187
+ READY_QUERY_EVENT,
170
188
  createIframeCallRequest,
171
189
  createIframeCallSuccessResponse,
172
190
  createIframeCallErrorResponse,
173
191
  createIframeCallNotify,
174
192
  parseIframeCallMessage,
175
193
  createIframeWindowTransport,
176
- createParentWindowTransport
194
+ createParentWindowTransport,
195
+ validateInbound
177
196
  };
package/dist/host.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HostDebugEvent, C as CommandMap, I as IframeCallControllerOptions, a as IframeCallController, S as SerializedIframeCallError } from './messages-uOmcU6eT.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-uOmcU6eT.js';
1
+ import { H as HostDebugEvent, C as CommandMap, I as IframeCallControllerOptions, a as IframeCallController, b as IframeCallControllerOriginOptions, c as IframeCallControllerBaseOptions, S as SerializedIframeCallError } from './messages-Bk4RCDOd.js';
2
+ export { d as CommandArgs, e as CommandHandler, f as CommandResult, g as CommandRunner, h as IframeCallCallOptions, i as IframeCallLogger, j as IframeCallNotify, k as IframeCallRequest, l as IframeCallResponse, m as IframeCallTransferable, n as IframeCallTransport, o as IframeCallTransportEvent, N as NotifyHandler, P as ParsedIframeCallMessage, R as ReadyPolicy, p as ReservedNotificationName, q as createIframeCallError, r as createIframeCallErrorResponse, s as createIframeCallNotify, t as createIframeCallRequest, u as createIframeCallSuccessResponse, v as createIframeWindowTransport, w as isSerializedIframeCallError, x as parseIframeCallMessage, y as serializeIframeCallError } from './messages-Bk4RCDOd.js';
3
3
 
4
4
  /** consoleDebugLogger 옵션. prefix를 명시하지 않으면 [iframecall:host]가 기본값이다. */
5
5
  type ConsoleDebugLoggerOptions = {
@@ -29,10 +29,9 @@ type UseIframeCallControllerDebugLog = boolean | {
29
29
  * useIframeCallController에 전달하는 옵션.
30
30
  * iframe element는 callback ref로 받으므로 controller 생성 옵션에서 제외한다.
31
31
  */
32
- type UseIframeCallControllerOptions<TCommands extends CommandMap<TCommands>> = {
33
- readonly targetOrigin: string;
32
+ type UseIframeCallControllerOptions<TCommands extends CommandMap<TCommands>> = IframeCallControllerOriginOptions & {
34
33
  readonly debugLog?: UseIframeCallControllerDebugLog;
35
- } & Pick<IframeCallControllerOptions<TCommands>, "allowedOrigins" | "defaultTimeoutMs" | "generateId" | "logger" | "readyPolicy" | "readyQueueLimit" | "readyTimeoutMs" | "transport">;
34
+ } & Pick<IframeCallControllerBaseOptions<TCommands>, "defaultTimeoutMs" | "generateId" | "logger" | "readyPolicy" | "readyQueueLimit" | "readyTimeoutMs" | "transport">;
36
35
  /** useIframeCallController가 반환하는 handle. mount 전에는 controller가 null이다. */
37
36
  type UseIframeCallControllerResult<TCommands extends CommandMap<TCommands>, TNotificationsFromIframe = Record<string, unknown>> = {
38
37
  readonly iframeRef: (node: HTMLIFrameElement | null) => void;
@@ -49,4 +48,4 @@ type UseIframeCallControllerResult<TCommands extends CommandMap<TCommands>, TNot
49
48
  */
50
49
  declare function useIframeCallController<TCommands extends CommandMap<TCommands>, TNotificationsFromIframe = Record<string, unknown>>(options: UseIframeCallControllerOptions<TCommands>): UseIframeCallControllerResult<TCommands, TNotificationsFromIframe>;
51
50
 
52
- export { CommandMap, HostDebugEvent, IframeCallController, IframeCallControllerOptions, type IframeCallControllerStatus, SerializedIframeCallError, type UseIframeCallControllerDebugLog, type UseIframeCallControllerOptions, type UseIframeCallControllerResult, consoleDebugLogger, createIframeCallController, useIframeCallController };
51
+ export { CommandMap, HostDebugEvent, IframeCallController, IframeCallControllerOptions, IframeCallControllerOriginOptions, type IframeCallControllerStatus, SerializedIframeCallError, type UseIframeCallControllerDebugLog, type UseIframeCallControllerOptions, type UseIframeCallControllerResult, consoleDebugLogger, createIframeCallController, useIframeCallController };
package/dist/host.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ READY_QUERY_EVENT,
2
3
  createIframeCallError,
3
4
  createIframeCallErrorResponse,
4
5
  createIframeCallNotify,
@@ -7,8 +8,9 @@ import {
7
8
  createIframeWindowTransport,
8
9
  isSerializedIframeCallError,
9
10
  parseIframeCallMessage,
10
- serializeIframeCallError
11
- } from "./chunk-RSUK22E2.js";
11
+ serializeIframeCallError,
12
+ validateInbound
13
+ } from "./chunk-UNM5VKFB.js";
12
14
 
13
15
  // src/host/consoleDebugLogger.ts
14
16
  function consoleDebugLogger(options = {}) {
@@ -144,103 +146,121 @@ function createNotifyHandlerRegistry() {
144
146
  };
145
147
  }
146
148
 
147
- // src/host/pendingCallRegistry.ts
148
- function createPendingCallRegistry(transport, targetOrigin) {
149
+ // src/host/invocationLedger.ts
150
+ function createInvocationLedger(options) {
151
+ const queued = /* @__PURE__ */ new Map();
149
152
  const pending = /* @__PURE__ */ new Map();
150
- function clearCallTimeout(call) {
151
- if (call.timeoutId !== null) {
152
- clearTimeout(call.timeoutId);
153
+ let ready = false;
154
+ function clearDeadline(invocation) {
155
+ if (invocation.timeoutId !== null) {
156
+ clearTimeout(invocation.timeoutId);
153
157
  }
154
158
  }
155
- return {
156
- add(id, call) {
157
- pending.set(id, call);
158
- },
159
- delete(id) {
160
- pending.delete(id);
161
- },
162
- settle(id, response) {
163
- const call = pending.get(id);
164
- if (call === void 0) {
165
- return;
166
- }
159
+ function rejectInvocation(invocation, error) {
160
+ clearDeadline(invocation);
161
+ invocation.reject(error);
162
+ }
163
+ function expire(id, command, timeoutMs) {
164
+ var _a;
165
+ const invocation = (_a = queued.get(id)) != null ? _a : pending.get(id);
166
+ if (invocation === void 0) return;
167
+ queued.delete(id);
168
+ pending.delete(id);
169
+ rejectInvocation(
170
+ invocation,
171
+ createIframeCallError("timeout", "Command timed out.", {
172
+ command,
173
+ details: { timeoutMs }
174
+ })
175
+ );
176
+ }
177
+ function post(id, invocation) {
178
+ options.onCommandSent(invocation.command, invocation.args);
179
+ pending.set(id, invocation);
180
+ try {
181
+ options.transport.post(
182
+ createIframeCallRequest(id, invocation.command, invocation.args),
183
+ options.targetOrigin,
184
+ invocation.transfer
185
+ );
186
+ } catch (error) {
167
187
  pending.delete(id);
168
- clearCallTimeout(call);
169
- if (response.ok) {
170
- call.resolve(response.value);
171
- return;
172
- }
173
- call.reject(response.error);
174
- },
175
- post(id, cmd, args, transfer) {
176
- try {
177
- transport.post(
178
- createIframeCallRequest(id, cmd, args),
179
- targetOrigin,
180
- transfer
181
- );
182
- } catch (error) {
183
- const call = pending.get(id);
184
- pending.delete(id);
185
- if (call !== void 0) {
186
- clearCallTimeout(call);
187
- call.reject(
188
- createIframeCallError(
189
- "invalid_args",
190
- "Failed to post command request.",
191
- {
192
- command: cmd,
193
- details: error
194
- }
195
- )
196
- );
197
- }
198
- }
199
- },
200
- rejectAll(buildError) {
201
- for (const [, call] of pending) {
202
- clearCallTimeout(call);
203
- call.reject(buildError(call.command));
204
- }
205
- pending.clear();
206
- },
207
- getCommand(id) {
208
- var _a;
209
- return (_a = pending.get(id)) == null ? void 0 : _a.command;
210
- }
211
- };
212
- }
213
-
214
- // src/host/readyQueue.ts
215
- function createReadyQueue() {
216
- const queued = /* @__PURE__ */ new Map();
217
- function clearCallTimeout(call) {
218
- if (call.timeoutId !== null) {
219
- clearTimeout(call.timeoutId);
188
+ rejectInvocation(
189
+ invocation,
190
+ createIframeCallError("invalid_args", "Failed to post command request.", {
191
+ command: invocation.command,
192
+ details: error
193
+ })
194
+ );
220
195
  }
221
196
  }
222
197
  return {
223
- size() {
224
- return queued.size;
225
- },
226
- add(id, call) {
227
- queued.set(id, call);
228
- },
229
- delete(id) {
230
- queued.delete(id);
198
+ invoke(generateId, command, args, callOptions) {
199
+ if (!ready && options.readyPolicy === "reject") {
200
+ return Promise.reject(
201
+ createIframeCallError("not_ready", "Iframe is not ready.", {
202
+ command
203
+ })
204
+ );
205
+ }
206
+ return new Promise((resolve, reject) => {
207
+ const id = generateId();
208
+ const timeoutMs = callOptions.timeoutMs;
209
+ const timeoutId = timeoutMs === 0 || timeoutMs === Number.POSITIVE_INFINITY ? null : setTimeout(() => expire(id, command, timeoutMs), timeoutMs);
210
+ const invocation = {
211
+ command,
212
+ args,
213
+ transfer: callOptions.transfer,
214
+ timeoutId,
215
+ resolve,
216
+ reject
217
+ };
218
+ if (!ready) {
219
+ if (queued.size >= options.readyQueueLimit) {
220
+ clearDeadline(invocation);
221
+ reject(
222
+ createIframeCallError("queue_overflow", "Ready queue overflow.", {
223
+ command,
224
+ details: { readyQueueLimit: options.readyQueueLimit }
225
+ })
226
+ );
227
+ return;
228
+ }
229
+ queued.set(id, invocation);
230
+ return;
231
+ }
232
+ post(id, invocation);
233
+ });
231
234
  },
232
- flush(forward) {
233
- for (const [id, call] of queued) {
235
+ acceptReady() {
236
+ if (ready) return;
237
+ ready = true;
238
+ for (const [id, invocation] of queued) {
234
239
  queued.delete(id);
235
- forward(id, call);
240
+ post(id, invocation);
241
+ }
242
+ },
243
+ settle(response) {
244
+ const invocation = pending.get(response.id);
245
+ if (invocation === void 0) return void 0;
246
+ pending.delete(response.id);
247
+ clearDeadline(invocation);
248
+ if (response.ok) {
249
+ invocation.resolve(response.value);
250
+ } else {
251
+ invocation.reject(response.error);
236
252
  }
253
+ return invocation.command;
237
254
  },
238
- rejectAll(buildError) {
239
- for (const [, call] of queued) {
240
- clearCallTimeout(call);
241
- call.reject(buildError(call.command));
255
+ terminate(buildError) {
256
+ for (const [, invocation] of queued) {
257
+ rejectInvocation(invocation, buildError(invocation.command));
242
258
  }
243
259
  queued.clear();
260
+ for (const [, invocation] of pending) {
261
+ rejectInvocation(invocation, buildError(invocation.command));
262
+ }
263
+ pending.clear();
244
264
  }
245
265
  };
246
266
  }
@@ -248,16 +268,16 @@ function createReadyQueue() {
248
268
  // src/host/controller.ts
249
269
  function createIframeCallController(options) {
250
270
  var _a, _b, _c, _d, _e, _f, _g;
251
- const targetOrigin = requireTargetOrigin(options.targetOrigin);
252
- const allowedOrigins = new Set((_a = options.allowedOrigins) != null ? _a : [targetOrigin]);
253
- const transport = (_b = options.transport) != null ? _b : createIframeWindowTransport(options.iframe);
254
- const generateId = (_c = options.generateId) != null ? _c : createDefaultRequestId;
255
- const defaultTimeoutMs = (_d = options.defaultTimeoutMs) != null ? _d : 3e4;
256
- const readyTimeoutMs = (_e = options.readyTimeoutMs) != null ? _e : defaultTimeoutMs;
257
- const readyPolicy = (_f = options.readyPolicy) != null ? _f : "queue";
258
- const readyQueueLimit = (_g = options.readyQueueLimit) != null ? _g : Number.POSITIVE_INFINITY;
259
- const pending = createPendingCallRegistry(transport, targetOrigin);
260
- const queue = createReadyQueue();
271
+ const transport = (_a = options.transport) != null ? _a : createIframeWindowTransport(options.iframe);
272
+ const { targetOrigin, allowedOrigins, requireSource } = resolveOriginPolicy(
273
+ options,
274
+ transport
275
+ );
276
+ const generateId = (_b = options.generateId) != null ? _b : createDefaultRequestId;
277
+ const defaultTimeoutMs = (_c = options.defaultTimeoutMs) != null ? _c : 3e4;
278
+ const readyTimeoutMs = (_d = options.readyTimeoutMs) != null ? _d : defaultTimeoutMs;
279
+ const readyPolicy = (_e = options.readyPolicy) != null ? _e : "queue";
280
+ const readyQueueLimit = (_f = options.readyQueueLimit) != null ? _f : Number.POSITIVE_INFINITY;
261
281
  const notifyRegistry = createNotifyHandlerRegistry();
262
282
  const debugSubscribers = /* @__PURE__ */ new Set();
263
283
  function emitDebug(event) {
@@ -270,21 +290,29 @@ function createIframeCallController(options) {
270
290
  }
271
291
  }
272
292
  }
293
+ const ledger = createInvocationLedger({
294
+ transport,
295
+ targetOrigin,
296
+ readyPolicy,
297
+ readyQueueLimit,
298
+ onCommandSent(command, args) {
299
+ emitDebug({ type: "commandSentToIframe", command, args });
300
+ }
301
+ });
273
302
  const lifecycle = createControllerLifecycle({
274
303
  readyTimeoutMs,
275
304
  onTerminate(error) {
276
305
  const buildLifecycleError = (command) => createCallLifecycleError(error, command);
277
- pending.rejectAll(buildLifecycleError);
278
- queue.rejectAll(buildLifecycleError);
306
+ ledger.terminate(buildLifecycleError);
279
307
  }
280
308
  });
281
309
  const unsubscribeTransport = transport.subscribe(
282
310
  createTransportRouter({
283
311
  lifecycle,
284
312
  allowedOrigins,
313
+ requireSource,
285
314
  transport,
286
- pending,
287
- queue,
315
+ ledger,
288
316
  notifyRegistry,
289
317
  logger: options.logger,
290
318
  emitDebug
@@ -294,59 +322,24 @@ function createIframeCallController(options) {
294
322
  unsubscribeTransport();
295
323
  notifyRegistry.clear();
296
324
  });
325
+ try {
326
+ transport.post(
327
+ createIframeCallNotify(READY_QUERY_EVENT, null),
328
+ targetOrigin
329
+ );
330
+ } catch (error) {
331
+ (_g = options.logger) == null ? void 0 : _g.warn("iframecall postMessage failed.", error);
332
+ }
297
333
  const invoke = (cmd, args, callOptions) => {
298
334
  var _a2;
299
335
  const terminatedError = lifecycle.getTerminatedError();
300
336
  if (terminatedError !== null) {
301
337
  return Promise.reject(terminatedError);
302
338
  }
303
- if (!lifecycle.isReady() && readyPolicy === "reject") {
304
- return Promise.reject(
305
- createIframeCallError("not_ready", "Iframe is not ready.", {
306
- command: cmd
307
- })
308
- );
309
- }
310
- const id = generateId();
311
339
  const timeoutMs = (_a2 = callOptions == null ? void 0 : callOptions.timeoutMs) != null ? _a2 : defaultTimeoutMs;
312
- return new Promise((resolve, reject) => {
313
- const timeoutId = timeoutMs === 0 || timeoutMs === Number.POSITIVE_INFINITY ? null : setTimeout(() => {
314
- pending.delete(id);
315
- queue.delete(id);
316
- reject(
317
- createIframeCallError("timeout", "Command timed out.", {
318
- command: cmd,
319
- details: { timeoutMs }
320
- })
321
- );
322
- }, timeoutMs);
323
- const call = {
324
- command: cmd,
325
- timeoutId,
326
- resolve,
327
- reject
328
- };
329
- if (!lifecycle.isReady()) {
330
- if (queue.size() >= readyQueueLimit) {
331
- if (timeoutId !== null) clearTimeout(timeoutId);
332
- reject(
333
- createIframeCallError("queue_overflow", "Ready queue overflow.", {
334
- command: cmd,
335
- details: { readyQueueLimit }
336
- })
337
- );
338
- return;
339
- }
340
- queue.add(id, {
341
- ...call,
342
- args,
343
- transfer: callOptions == null ? void 0 : callOptions.transfer
344
- });
345
- return;
346
- }
347
- emitDebug({ type: "commandSentToIframe", command: cmd, args });
348
- pending.add(id, call);
349
- pending.post(id, cmd, args, callOptions == null ? void 0 : callOptions.transfer);
340
+ return ledger.invoke(generateId, cmd, args, {
341
+ timeoutMs,
342
+ transfer: callOptions == null ? void 0 : callOptions.transfer
350
343
  });
351
344
  };
352
345
  const controller = {
@@ -410,24 +403,25 @@ function createTransportRouter(deps) {
410
403
  const {
411
404
  lifecycle,
412
405
  allowedOrigins,
406
+ requireSource,
413
407
  transport,
414
- pending,
415
- queue,
408
+ ledger,
416
409
  notifyRegistry,
417
410
  logger,
418
411
  emitDebug
419
412
  } = deps;
420
413
  return (event) => {
421
414
  if (lifecycle.isTerminated()) return;
422
- if (!allowedOrigins.has(event.origin)) return;
423
- if (transport.expectedSource !== void 0 && event.source !== transport.expectedSource) {
424
- return;
425
- }
426
- const parsed = parseIframeCallMessage(event.data);
415
+ const inbound = validateInbound(event, {
416
+ allowedOrigins,
417
+ expectedSource: transport.expectedSource,
418
+ requireSource
419
+ });
420
+ if (!inbound.accepted) return;
421
+ const parsed = inbound.message;
427
422
  if ((parsed == null ? void 0 : parsed.type) === "response") {
428
423
  const responseMessage = parsed.message;
429
- const command = pending.getCommand(responseMessage.id);
430
- pending.settle(responseMessage.id, responseMessage);
424
+ const command = ledger.settle(responseMessage);
431
425
  if (command !== void 0) {
432
426
  if (responseMessage.ok) {
433
427
  emitDebug({
@@ -448,7 +442,7 @@ function createTransportRouter(deps) {
448
442
  if ((parsed == null ? void 0 : parsed.type) !== "notify") return;
449
443
  const { event: notifyEvent, payload } = parsed.message;
450
444
  if (notifyEvent === "ready") {
451
- handleReadyNotify(payload, lifecycle, queue, pending, logger, emitDebug);
445
+ handleReadyNotify(payload, lifecycle, ledger, logger, emitDebug);
452
446
  return;
453
447
  }
454
448
  if (notifyEvent === "terminated") {
@@ -476,9 +470,11 @@ function createTransportRouter(deps) {
476
470
  });
477
471
  };
478
472
  }
479
- function handleReadyNotify(payload, lifecycle, queue, pending, logger, emitDebug) {
473
+ function handleReadyNotify(payload, lifecycle, ledger, logger, emitDebug) {
480
474
  if (lifecycle.isReady()) {
481
- logger == null ? void 0 : logger.warn("iframecall duplicate ready ignored.", payload);
475
+ if (!(isRecord(payload) && payload.requested === true)) {
476
+ logger == null ? void 0 : logger.warn("iframecall duplicate ready ignored.", payload);
477
+ }
482
478
  return;
483
479
  }
484
480
  if (!isSupportedReadyPayload(payload)) {
@@ -493,15 +489,7 @@ function handleReadyNotify(payload, lifecycle, queue, pending, logger, emitDebug
493
489
  }
494
490
  emitDebug({ type: "readyReceived", payload });
495
491
  lifecycle.markReady();
496
- queue.flush((id, call) => {
497
- emitDebug({
498
- type: "commandSentToIframe",
499
- command: call.command,
500
- args: call.args
501
- });
502
- pending.add(id, call);
503
- pending.post(id, call.command, call.args, call.transfer);
504
- });
492
+ ledger.acceptReady();
505
493
  }
506
494
  function createCallLifecycleError(error, command) {
507
495
  if (error.code !== "timeout" || error.command !== void 0) {
@@ -521,6 +509,34 @@ function getTerminatedCause(payload) {
521
509
  function isSupportedReadyPayload(payload) {
522
510
  return isRecord(payload) && payload.protocolVersion === 1;
523
511
  }
512
+ function resolveOriginPolicy(options, transport) {
513
+ var _a;
514
+ if (options.opaqueOrigin !== true) {
515
+ const targetOrigin = requireTargetOrigin(options.targetOrigin);
516
+ return {
517
+ targetOrigin,
518
+ allowedOrigins: new Set((_a = options.allowedOrigins) != null ? _a : [targetOrigin]),
519
+ requireSource: false
520
+ };
521
+ }
522
+ if (options.targetOrigin !== void 0 || options.allowedOrigins !== void 0) {
523
+ throw createIframeCallError(
524
+ "invalid_origin",
525
+ "opaqueOrigin cannot be combined with targetOrigin or allowedOrigins."
526
+ );
527
+ }
528
+ if (transport.expectedSource === void 0) {
529
+ throw createIframeCallError(
530
+ "invalid_origin",
531
+ "opaqueOrigin requires transport.expectedSource."
532
+ );
533
+ }
534
+ return {
535
+ targetOrigin: "*",
536
+ allowedOrigins: /* @__PURE__ */ new Set(["null"]),
537
+ requireSource: true
538
+ };
539
+ }
524
540
  function requireTargetOrigin(targetOrigin) {
525
541
  if (targetOrigin.length === 0 || targetOrigin === "*" || targetOrigin === "null") {
526
542
  throw createIframeCallError(
@@ -545,19 +561,20 @@ function useIframeCallController(options) {
545
561
  const [status, setStatus] = useState("pending");
546
562
  const [terminationError, setTerminationError] = useState(null);
547
563
  const [readyError, setReadyError] = useState(null);
564
+ const controllerRef = useRef(null);
548
565
  const iframeRef = useCallback((node) => {
549
566
  iframeElRef.current = node;
550
567
  setIframeAttached(node !== null);
551
568
  }, []);
552
569
  useEffect(() => {
553
570
  if (!iframeAttached) return;
571
+ if (controllerRef.current !== null) return;
554
572
  const iframeEl = iframeElRef.current;
555
573
  if (iframeEl === null) return;
556
574
  const opts = optionsRef.current;
557
575
  const next = createIframeCallController({
558
576
  iframe: iframeEl,
559
- targetOrigin: opts.targetOrigin,
560
- allowedOrigins: opts.allowedOrigins,
577
+ ...pickOriginOptions(opts),
561
578
  defaultTimeoutMs: opts.defaultTimeoutMs,
562
579
  generateId: opts.generateId,
563
580
  logger: opts.logger,
@@ -566,13 +583,15 @@ function useIframeCallController(options) {
566
583
  readyTimeoutMs: opts.readyTimeoutMs,
567
584
  transport: opts.transport
568
585
  });
586
+ const controller2 = next;
587
+ controllerRef.current = controller2;
569
588
  let cancelled = false;
570
- setController(
571
- next
572
- );
573
- setStatus("pending");
574
- setTerminationError(null);
575
- setReadyError(null);
589
+ if (controllerRef.current === controller2) {
590
+ setController(controller2);
591
+ setStatus("pending");
592
+ setTerminationError(null);
593
+ setReadyError(null);
594
+ }
576
595
  next.ready.then(
577
596
  () => {
578
597
  if (cancelled) return;
@@ -604,6 +623,7 @@ function useIframeCallController(options) {
604
623
  unsubscribeDebug();
605
624
  }
606
625
  void next.dispose("host-unmount");
626
+ if (controllerRef.current === controller2) controllerRef.current = null;
607
627
  setController(null);
608
628
  };
609
629
  }, [iframeAttached]);
@@ -615,6 +635,13 @@ function useIframeCallController(options) {
615
635
  readyError
616
636
  };
617
637
  }
638
+ function pickOriginOptions(opts) {
639
+ return {
640
+ opaqueOrigin: opts.opaqueOrigin,
641
+ targetOrigin: opts.targetOrigin,
642
+ allowedOrigins: opts.allowedOrigins
643
+ };
644
+ }
618
645
  export {
619
646
  consoleDebugLogger,
620
647
  createIframeCallController,
package/dist/iframe.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { x as IframeDebugEvent, C as CommandMap, y as IframeCallRunnerOptions, z as IframeCallRunnerHandle, A as CommandsConstructor, B as IframeCallRunnerClassOptions, D as IframeHelper } from './messages-uOmcU6eT.js';
2
- export { b as CommandArgs, c as CommandHandler, d as CommandResult, e as CommandRunner, E as DomainNotificationKey, 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, S as SerializedIframeCallError, o as createIframeCallError, p as createIframeCallErrorResponse, q as createIframeCallNotify, r as createIframeCallRequest, s as createIframeCallSuccessResponse, F as createParentWindowTransport, u as isSerializedIframeCallError, v as parseIframeCallMessage, w as serializeIframeCallError } from './messages-uOmcU6eT.js';
1
+ import { z as IframeDebugEvent, C as CommandMap, A as IframeCallRunnerOptions, B as IframeCallRunnerHandle, D as CommandsConstructor, E as IframeCallRunnerClassOptions, F as IframeHelper } from './messages-Bk4RCDOd.js';
2
+ export { d as CommandArgs, e as CommandHandler, f as CommandResult, g as CommandRunner, G as DomainNotificationKey, h as IframeCallCallOptions, i as IframeCallLogger, j as IframeCallNotify, k as IframeCallRequest, l as IframeCallResponse, m as IframeCallTransferable, n as IframeCallTransport, o as IframeCallTransportEvent, N as NotifyHandler, P as ParsedIframeCallMessage, R as ReadyPolicy, p as ReservedNotificationName, S as SerializedIframeCallError, q as createIframeCallError, r as createIframeCallErrorResponse, s as createIframeCallNotify, t as createIframeCallRequest, u as createIframeCallSuccessResponse, J as createParentWindowTransport, w as isSerializedIframeCallError, x as parseIframeCallMessage, y as serializeIframeCallError } from './messages-Bk4RCDOd.js';
3
3
 
4
4
  /** consoleDebugLogger 옵션. prefix를 명시하지 않으면 [iframecall:iframe]가 기본값이다. */
5
5
  type ConsoleDebugLoggerOptions = {
package/dist/iframe.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ READY_QUERY_EVENT,
2
3
  createIframeCallError,
3
4
  createIframeCallErrorResponse,
4
5
  createIframeCallNotify,
@@ -7,8 +8,9 @@ import {
7
8
  createParentWindowTransport,
8
9
  isSerializedIframeCallError,
9
10
  parseIframeCallMessage,
10
- serializeIframeCallError
11
- } from "./chunk-RSUK22E2.js";
11
+ serializeIframeCallError,
12
+ validateInbound
13
+ } from "./chunk-UNM5VKFB.js";
12
14
 
13
15
  // src/iframe/consoleDebugLogger.ts
14
16
  function consoleDebugLogger(options = {}) {
@@ -45,6 +47,7 @@ function createIframeCallRunner(options) {
45
47
  const transport = (_b = options.transport) != null ? _b : createParentWindowTransport();
46
48
  let disposing = false;
47
49
  let disposed = false;
50
+ let lifecycleReadySent = false;
48
51
  const debugSubscribers = /* @__PURE__ */ new Set();
49
52
  function emitDebug(event) {
50
53
  var _a2;
@@ -64,6 +67,7 @@ function createIframeCallRunner(options) {
64
67
  },
65
68
  sendLifecycleReady() {
66
69
  if (disposing || disposed) return;
70
+ lifecycleReadySent = true;
67
71
  safePost(createIframeCallNotify("ready", { protocolVersion: 1 }));
68
72
  },
69
73
  debug: {
@@ -79,14 +83,24 @@ function createIframeCallRunner(options) {
79
83
  const { commands, dispatch, commandRunHook } = resolveCommandSource(options, iframeHelper);
80
84
  const unsubscribeTransport = transport.subscribe((event) => {
81
85
  if (disposing || disposed) return;
82
- if (!allowedOrigins.has(event.origin)) {
83
- return;
84
- }
85
- if (transport.expectedSource !== void 0 && event.source !== transport.expectedSource) {
86
- return;
87
- }
88
- const parsed = parseIframeCallMessage(event.data);
86
+ const inbound = validateInbound(event, {
87
+ allowedOrigins,
88
+ expectedSource: transport.expectedSource
89
+ });
90
+ if (!inbound.accepted) return;
91
+ const parsed = inbound.message;
89
92
  if ((parsed == null ? void 0 : parsed.type) === "notify") {
93
+ if (parsed.message.event === READY_QUERY_EVENT) {
94
+ if (lifecycleReadySent) {
95
+ safePost(
96
+ createIframeCallNotify("ready", {
97
+ protocolVersion: 1,
98
+ requested: true
99
+ })
100
+ );
101
+ }
102
+ return;
103
+ }
90
104
  emitDebug({
91
105
  type: "notificationReceivedFromHost",
92
106
  event: parsed.message.event,
@@ -271,6 +285,7 @@ function useIframeCallRunner(options) {
271
285
  const runnerRef = useRef(null);
272
286
  const [isActive, setIsActive] = useState(false);
273
287
  useEffect(() => {
288
+ if (runnerRef.current !== null) return;
274
289
  const opts = optionsRef.current;
275
290
  const runner2 = createIframeCallRunner({
276
291
  targetOrigin: opts.targetOrigin,
@@ -281,7 +296,7 @@ function useIframeCallRunner(options) {
281
296
  transport: opts.transport
282
297
  });
283
298
  runnerRef.current = runner2;
284
- setIsActive(true);
299
+ if (runnerRef.current === runner2) setIsActive(true);
285
300
  let unsubscribeDebug = null;
286
301
  const debugLog = opts.debugLog;
287
302
  if (debugLog) {
@@ -294,7 +309,7 @@ function useIframeCallRunner(options) {
294
309
  return () => {
295
310
  if (unsubscribeDebug !== null) unsubscribeDebug();
296
311
  runner2.dispose("react_unmount");
297
- runnerRef.current = null;
312
+ if (runnerRef.current === runner2) runnerRef.current = null;
298
313
  };
299
314
  }, []);
300
315
  const runner = runnerRef.current;
@@ -18,8 +18,9 @@ type IframeCallTransport = {
18
18
  };
19
19
  /**
20
20
  * host 측에서 사용하는 transport. 자식 iframe의 contentWindow로 postMessage를 보낸다.
21
- * `expectedSource`는 contentWindow가 swap되기 시점에 캐시되므로,
22
- * iframe src 변경처럼 contentWindow가 교체되는 경우 transport도 다시 만들어야 한다.
21
+ * `expectedSource`는 생성 시점의 contentWindow(WindowProxy)를 캐시한다.
22
+ * 같은 iframe 요소의 src 변경(navigation)은 WindowProxy identity를 유지하지만,
23
+ * iframe 요소 자체를 교체하면 contentWindow가 달라지므로 transport도 다시 만들어야 한다.
23
24
  */
24
25
  declare function createIframeWindowTransport(iframe: HTMLIFrameElement): IframeCallTransport;
25
26
  /**
@@ -96,13 +97,33 @@ type IframeCallLogger = {
96
97
  readonly info?: (message: string, detail?: unknown) => void;
97
98
  };
98
99
  /** host controller 생성 옵션. */
99
- type IframeCallControllerOptions<TCommands extends CommandMap<TCommands>> = {
100
- /** 통신 대상 iframe element. transport 미지정 시 contentWindow 기준 기본 transport를 만든다. */
101
- readonly iframe: HTMLIFrameElement;
100
+ type IframeCallControllerOptions<TCommands extends CommandMap<TCommands>> = IframeCallControllerOriginOptions & IframeCallControllerBaseOptions<TCommands>;
101
+ /**
102
+ * controller의 origin 정책 옵션.
103
+ * 일반 origin iframe은 targetOrigin을 명시하고, sandbox로 격리된 opaque origin("null") iframe은 opaqueOrigin으로 opt-in한다.
104
+ */
105
+ type IframeCallControllerOriginOptions = {
106
+ /** opaque origin 모드를 끈다. 생략과 같다. */
107
+ readonly opaqueOrigin?: false;
102
108
  /** postMessage targetOrigin. wildcard("*"/"null"/빈 문자열)는 거부한다. */
103
109
  readonly targetOrigin: string;
104
110
  /** 수신 시 허용할 origin 목록. 미지정이면 targetOrigin 단일 값을 사용한다. */
105
111
  readonly allowedOrigins?: readonly string[];
112
+ } | {
113
+ /**
114
+ * opaque origin("null") iframe과 통신한다. 송신은 "*"로, 수신은 origin "null"이면서
115
+ * source가 transport.expectedSource와 같은 메시지만 받는다. expectedSource가 없으면 생성 시 거부한다.
116
+ */
117
+ readonly opaqueOrigin: true;
118
+ /** opaque origin 모드에서는 지정할 수 없다. */
119
+ readonly targetOrigin?: never;
120
+ /** opaque origin 모드에서는 지정할 수 없다. */
121
+ readonly allowedOrigins?: never;
122
+ };
123
+ /** origin 정책을 제외한 controller 옵션. */
124
+ type IframeCallControllerBaseOptions<TCommands extends CommandMap<TCommands>> = {
125
+ /** 통신 대상 iframe element. transport 미지정 시 contentWindow 기준 기본 transport를 만든다. */
126
+ readonly iframe: HTMLIFrameElement;
106
127
  /** ready 이전 호출 처리 정책. "queue"는 대기열에 쌓고, "reject"는 즉시 거부한다. */
107
128
  readonly readyPolicy?: ReadyPolicy;
108
129
  /** queue 정책일 때 대기열 최대 크기. 초과 호출은 queue_overflow 에러로 거부한다. */
@@ -330,4 +351,4 @@ declare function createIframeCallNotify(event: string, payload: unknown): Iframe
330
351
  */
331
352
  declare function parseIframeCallMessage(value: unknown): ParsedIframeCallMessage | null;
332
353
 
333
- export { type CommandsConstructor as A, type IframeCallRunnerClassOptions as B, type CommandMap as C, type IframeHelper as D, type DomainNotificationKey as E, createParentWindowTransport as F, type HostDebugEvent as H, type IframeCallControllerOptions as I, type NotifyHandler as N, type ParsedIframeCallMessage as P, type ReadyPolicy as R, type SerializedIframeCallError as S, type IframeCallController as a, type CommandArgs as b, type CommandHandler as c, type CommandResult as d, type CommandRunner as e, type IframeCallCallOptions as f, type IframeCallLogger as g, type IframeCallNotify as h, type IframeCallRequest as i, type IframeCallResponse as j, type IframeCallTransferable as k, type IframeCallTransport as l, type IframeCallTransportEvent as m, type ReservedNotificationName as n, createIframeCallError as o, createIframeCallErrorResponse as p, createIframeCallNotify as q, createIframeCallRequest as r, createIframeCallSuccessResponse as s, createIframeWindowTransport as t, isSerializedIframeCallError as u, parseIframeCallMessage as v, serializeIframeCallError as w, type IframeDebugEvent as x, type IframeCallRunnerOptions as y, type IframeCallRunnerHandle as z };
354
+ export { type IframeCallRunnerOptions as A, type IframeCallRunnerHandle as B, type CommandMap as C, type CommandsConstructor as D, type IframeCallRunnerClassOptions as E, type IframeHelper as F, type DomainNotificationKey as G, type HostDebugEvent as H, type IframeCallControllerOptions as I, createParentWindowTransport as J, type NotifyHandler as N, type ParsedIframeCallMessage as P, type ReadyPolicy as R, type SerializedIframeCallError as S, type IframeCallController as a, type IframeCallControllerOriginOptions as b, type IframeCallControllerBaseOptions as c, type CommandArgs as d, type CommandHandler as e, type CommandResult as f, type CommandRunner as g, type IframeCallCallOptions as h, type IframeCallLogger as i, type IframeCallNotify as j, type IframeCallRequest as k, type IframeCallResponse as l, type IframeCallTransferable as m, type IframeCallTransport as n, type IframeCallTransportEvent as o, type ReservedNotificationName as p, createIframeCallError as q, createIframeCallErrorResponse as r, createIframeCallNotify as s, createIframeCallRequest as t, createIframeCallSuccessResponse as u, createIframeWindowTransport as v, isSerializedIframeCallError as w, parseIframeCallMessage as x, serializeIframeCallError as y, type IframeDebugEvent as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cp949/iframecall",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "타입 안전한 host ↔ iframe postMessage 호출 라이브러리 (React 18/19 지원)",
5
5
  "keywords": [
6
6
  "iframe",
@@ -47,29 +47,28 @@
47
47
  "publishConfig": {
48
48
  "access": "public"
49
49
  },
50
- "scripts": {
51
- "build": "tsup",
52
- "dev": "tsup --watch",
53
- "lint": "eslint . --max-warnings 0",
54
- "check-types": "tsc --noEmit",
55
- "test": "vitest run tests/unit --passWithNoTests",
56
- "prepublishOnly": "pnpm run build"
57
- },
58
50
  "peerDependencies": {
59
51
  "@types/react": "^18.0.0 || ^19.0.0",
60
52
  "react": "^18.0.0 || ^19.0.0"
61
53
  },
62
54
  "devDependencies": {
63
- "@repo/eslint-config": "workspace:*",
64
- "@repo/typescript-config": "workspace:*",
55
+ "@repo/eslint-config": "0.0.0",
56
+ "@repo/typescript-config": "0.0.0",
65
57
  "@testing-library/dom": "^10.4.1",
66
58
  "@testing-library/react": "^16.3.2",
67
- "@types/react": "19.2.18",
59
+ "@types/react": "19.3.0",
68
60
  "eslint": "^9.39.5",
69
61
  "jsdom": "^29.1.1",
70
- "react": "^19.2.8",
62
+ "react": "^19.3.0",
71
63
  "tsup": "^8.5.1",
72
64
  "typescript": "6.0.3",
73
65
  "vitest": "^4.1.11"
66
+ },
67
+ "scripts": {
68
+ "build": "tsup",
69
+ "dev": "tsup --watch",
70
+ "lint": "eslint . --max-warnings 0",
71
+ "check-types": "tsc --noEmit",
72
+ "test": "vitest run tests/unit --passWithNoTests"
74
73
  }
75
- }
74
+ }