@bstage-sdk/bridge 0.9.1 → 0.10.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/README.md CHANGED
@@ -79,6 +79,62 @@ host.dispose()
79
79
  모두 맞는 메시지만 처리한다. 토큰은 프레임으로 가지 않는다 — 인증은 `fetchImpl`이 호스트 쪽에서 붙이고,
80
80
  프레임에는 상태·statusText·content-type·본문 문자열만 돌아간다.
81
81
 
82
+ ### 세션 수명과 프레임 자기 내비게이션
83
+
84
+ 템플릿은 자기 프레임을 다른 URL로 옮길 수 있다(`location = 'https://attacker/#' + session`). sandbox 플래그는
85
+ 내비게이션 뒤에도 남으므로 새 문서도 origin `"null"`이고 `contentWindow`도 같다 — `ev.source`·origin 검사만으로는
86
+ 옛 문서와 새 문서를 가를 수 없고, 세션은 srcdoc 본문에 있어 템플릿이 읽어 URL로 넘길 수 있다. 호스트가 iframe의
87
+ 두 번째 `load`에서 `dispose()`해도 새 문서의 스크립트는 `load`보다 먼저 돈다. SDK는 이 틈을 다음으로 닫는다
88
+ (호스트 코드 변경 없음).
89
+
90
+ - **세션당 문서 하나(프레임 토큰)**: 프레임 런타임이 부트스트랩 때 문서마다 128비트 토큰을 만들어 모든 봉투 최상위
91
+ `frame`에 싣는다. 호스트는 세션이 맞는 첫 메시지의 토큰에 세션을 묶고, 이후 **토큰이 없거나 다른** 메시지가 오면
92
+ 그 메시지를 처리하지 않고 즉시 `dispose()`한다. 같은 토큰의 `ready` 재전송에는 계속 답하지만, 다른 문서의 두 번째
93
+ 핸드셰이크는 성립하지 않는다. 세션을 URL로 받은 새 문서는 토큰을 모르므로 첫 메시지에서 세션이 끊긴다.
94
+ - **토큰은 CSPRNG로만 만든다**: `crypto.getRandomValues`가 없으면 `Math.random`으로 대신하지 않고 브리지를 열지 않는다(`frame-unload` 외에는 아무것도 보내지 않는다).
95
+ - **핸드셰이크 전에는 이벤트를 보내지 않는다**: `host.send`는 토큰이 묶이기 전에는 프레임에 보내지 않고 init에만 반영한다. init에 접히지 않는 `visibility`는 마지막 값을 묶인 직후(init 뒤) 한 번 보낸다. 공개되는 봉투 id 접두사와 따로 뽑는다.
96
+ - **토큰은 템플릿 코드에 노출하지 않는다**: 런타임 클로저에만 있고, 봉투는 객체 리터럴로 만들며(Object.prototype
97
+ setter를 부르지 않는다) 부트스트랩 때 `bind`해 둔 `parent.postMessage`로만 보낸다. 호스트 → 프레임 메시지
98
+ (init·응답·이벤트)에는 실리지 않는다. 그래서 템플릿이 `Object.assign`·`Function.prototype.*`·`Object.prototype`
99
+ setter를 가로채도 보이는 것은 세션뿐이다(테스트로 고정). 세션은 템플릿이 읽을 수 있다 — 같은 문서이고 srcdoc 본문에
100
+ 있기 때문이다. 세션은 "이 iframe의 메시지"라는 표식일 뿐 비밀로 취급하지 않는다.
101
+ - **`frame-unload`**: 부트스트랩이 `pagehide`(capture, 템플릿보다 먼저 등록)에서 `evt:frame-unload`를 동기로 보낸다.
102
+ **전송을 가장 먼저** 하고, 전송 경로는 객체 리터럴과 부트스트랩 때 잡아 둔 참조(`bind`한 `postMessage`,
103
+ `PageTransitionEvent.prototype.persisted` 원본 접근자를 `Function.prototype.call.bind`로 묶은 것)만 쓴다.
104
+ `clearInterval`·`clearTimeout`도 부트스트랩 때 잡고, 타이머·대기 요청 정리는 전송 뒤 각각 try로 감싼다. 그래서
105
+ 템플릿이 **리스너가 살아 있는 동안** 전역·프로토타입(`clearInterval`·`clearTimeout`·`Map.prototype.forEach`·`Error`·
106
+ `Function.prototype.call`·`persisted` 접근자)을 바꾸거나 던지게 해도 전송은 막히지 않는다(테스트로 고정).
107
+ `persisted` 읽기가 실패하면 bfcache 진입이 아닌 것으로 보고 보낸다(실패는 끊는 쪽으로).
108
+ 보장하지 **않는** 것: 템플릿이 `document.open()`으로 창의 이벤트 리스너를 모두 지운 뒤 내비게이션하면 `pagehide`
109
+ 처리기 자체가 돌지 않는다 — 이때는 프레임 토큰과 호스트의 두 번째 `load` dispose에 기댄다.
110
+ 호스트는 받는 즉시 `dispose()`한다 — 이후 메시지는 무시되고, dispose 뒤 끝난 요청의 응답도 프레임에 보내지 않는다.
111
+ 프레임 쪽 대기 요청은 `UNSUPPORTED`로 바로 거절된다. bfcache 진입(`persisted: true`)에는 보내지 않는다(복원되면 같은 문서가 계속 쓴다).
112
+ - **`onDispose(reason)`**(선택): 호스트가 dispose될 때 한 번 불린다. `'frame-unload'`·`'frame-mismatch'`는 호스트가
113
+ 스스로 끊은 경우, `'manual'`은 `dispose()` 호출이다. 임베더는 이 콜백에서 iframe을 바로 걷어 낼 수 있다(콜백 예외는 삼킨다).
114
+ `beforeunload`·`unload`는 쓰지 않는다 — `beforeunload`는 호스트 페이지 자체의 이동에도 프레임에 오므로 이동이
115
+ 취소되거나 bfcache에서 복원되면 멀쩡한 템플릿이 끊기고, 하위 프레임의 `unload` 리스너는 호스트 페이지의 bfcache를
116
+ 막고 폐기 중인 API다.
117
+
118
+ **남는 위험(정직하게)**
119
+
120
+ - 막는 것은 "새 문서가 유효한 교환을 하는 것"이다. 새 문서가 **메시지를 보내지 않고 기다리면**, 호스트가 알아채기
121
+ 전까지(`frame-unload` 처리 또는 두 번째 `load`) `host.send`로 보내는 이벤트(`locale`·`theme`·`member`의
122
+ `signedIn`·`nickname`·`slot.context`·`visibility`)를 받을 수 있다. Chromium은 다른 프로세스로 가는 하위 프레임
123
+ 내비게이션에서 옛 문서의 `pagehide`를 새 문서 커밋과 병렬로 돌리므로 이 틈은 0이 아니다. 이벤트·init에 민감 정보를
124
+ 싣지 않는다는 원칙이 여기서도 경계다.
125
+ - 토큰 비공개는 JS 수준 보장이다. 엔진 버그나 이 SDK가 놓친 관찰 경로로 원래 문서의 템플릿이 토큰을 얻어 URL로 넘기면,
126
+ 새 문서는 `frame-unload`가 처리되기 전까지 유효한 요청을 보낼 수 있다. 이 경우의 마지막 방어선은 `frame-unload`와
127
+ 호스트의 두 번째 `load` dispose다.
128
+ - **진행 중인 요청의 응답**: 원래 문서가 보낸 요청의 응답이 내비게이션 뒤·dispose 전에 끝나면, 호스트는 그 응답을
129
+ `iframe.contentWindow`(= 새 문서)로 보낸다. 새 문서는 응답 봉투를 읽을 수 있다. dispose 뒤에 끝난 응답은 버리지만
130
+ 호스트 쪽 fetch 자체를 중단하지는 않는다(dispose 시 진행 중 fetch abort는 후속 과제).
131
+ - 세션을 아는 쪽(= 그 프레임의 템플릿)은 잘못된 토큰을 보내 자기 세션을 끊을 수 있다. 자기 자신에 대한 서비스 거부일 뿐이다.
132
+ - 호스트는 srcdoc마다 새 세션(`createSession()`)을 만들고 세션 하나에 `createBridgeHost`를 하나만 둔다. 같은 세션으로
133
+ 호스트를 다시 만들면 묶임이 새로 시작된다.
134
+ - **같은 srcdoc의 재로드·재삽입에는 새 호스트가 필요하다**: iframe을 DOM에서 옮기거나 다시 넣거나 `srcdoc`를 다시 설정하면
135
+ 새 문서(새 프레임 토큰)가 뜨므로, 옛 호스트는 `frame-unload`나 `frame-mismatch`로 끊기고 그 문서는 브리지를 쓰지 못한다.
136
+ 이때는 새 세션으로 `createSrcdoc`·`createBridgeHost`를 다시 만든다(`onDispose`로 감지할 수 있다).
137
+
82
138
  ### fetch 정책
83
139
 
84
140
  `new URL(url, hostOrigin)`으로 정규화한 뒤 목적지를 네 갈래로 나눈다.
@@ -159,6 +215,7 @@ external에 인증을 붙이는 fetch를 주입하지 않는다. 외부 갈래
159
215
 
160
216
  오류 코드: `FORBIDDEN_DESTINATION`·`FORBIDDEN_METHOD`·`BODY_TOO_LARGE`·`RATE_LIMITED`·`BAD_REQUEST`·`TIMEOUT`·`NETWORK_ERROR`·`UNSUPPORTED`.
161
217
  프레임 요청은 15초 안에 응답이 없으면 `TIMEOUT`. 핸드셰이크(`ready`)는 init을 받을 때까지 재전송한다(처음 10초는 250ms, 이후 2초 간격).
218
+ 프레임 → 호스트 봉투에는 최상위 `frame`(프레임 토큰)이 붙고, 문서가 내려갈 때 `evt:frame-unload`(`FRAME_UNLOAD_EVENT`)가 간다(위 "세션 수명").
162
219
 
163
220
  ## 프레임 문서
164
221
 
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  bridgeRuntime,
3
3
  createBridgeFetch
4
- } from "./chunk-KQMJY4ZH.js";
4
+ } from "./chunk-N3JID7KQ.js";
5
5
 
6
6
  // src/protocol/envelope.ts
7
7
  var BRIDGE_VERSION = 1;
@@ -65,6 +65,7 @@ var REQUEST_TYPES = [
65
65
  var HOST_EVENT_TYPES = ["slot.context", "locale", "member", "theme", "visibility"];
66
66
  var READY_EVENT = "ready";
67
67
  var INIT_RESPONSE = "init";
68
+ var FRAME_UNLOAD_EVENT = "frame-unload";
68
69
  var BRIDGE_SURFACES = ["page", "slot"];
69
70
  function isBridgeSurface(value) {
70
71
  return BRIDGE_SURFACES.includes(value);
@@ -551,6 +552,7 @@ export {
551
552
  HOST_EVENT_TYPES,
552
553
  READY_EVENT,
553
554
  INIT_RESPONSE,
555
+ FRAME_UNLOAD_EVENT,
554
556
  BRIDGE_SURFACES,
555
557
  isBridgeSurface,
556
558
  IFRAME_ATTRIBUTES,
@@ -103,6 +103,20 @@ function bridgeRuntime(g, makeFetch, config) {
103
103
  const session = config.session;
104
104
  const hostOrigin = config.hostOrigin;
105
105
  const parent = g.parent;
106
+ const sendToHost = parent ? parent.postMessage.bind(parent) : void 0;
107
+ const stopInterval = clearInterval;
108
+ const stopTimeout = clearTimeout;
109
+ let readPersisted;
110
+ const ownDescriptor = Object.getOwnPropertyDescriptor;
111
+ const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty);
112
+ try {
113
+ const pte = g.PageTransitionEvent;
114
+ const desc = pte ? Object.getOwnPropertyDescriptor(pte.prototype, "persisted") : void 0;
115
+ if (desc && typeof desc.get === "function")
116
+ readPersisted = Function.prototype.call.bind(desc.get);
117
+ } catch {
118
+ readPersisted = void 0;
119
+ }
106
120
  const pending = /* @__PURE__ */ new Map();
107
121
  const listeners = /* @__PURE__ */ new Map();
108
122
  let init;
@@ -119,6 +133,17 @@ function bridgeRuntime(g, makeFetch, config) {
119
133
  return out;
120
134
  }
121
135
  const idPrefix = randomPrefix();
136
+ function secureToken() {
137
+ if (!g.crypto || typeof g.crypto.getRandomValues !== "function") return "";
138
+ const bytes = new Uint8Array(16);
139
+ g.crypto.getRandomValues(bytes);
140
+ let out = "";
141
+ for (let i = 0; i < bytes.length; i += 1) out += (bytes[i] + 256).toString(16).slice(1);
142
+ return out;
143
+ }
144
+ const frameToken = secureToken();
145
+ let closed = frameToken === "";
146
+ let unloaded = false;
122
147
  function nextId() {
123
148
  seq += 1;
124
149
  return idPrefix + "-" + seq;
@@ -130,9 +155,12 @@ function bridgeRuntime(g, makeFetch, config) {
130
155
  return err;
131
156
  }
132
157
  function post(kind, type, id, payload) {
133
- if (!parent) return;
158
+ if (!sendToHost || closed) return;
134
159
  const body = Object.assign({}, payload, { session });
135
- parent.postMessage({ v: 1, ns: NS, id, kind, type, payload: body }, hostOrigin);
160
+ sendToHost(
161
+ { v: 1, ns: NS, id, kind, type, payload: body, frame: frameToken },
162
+ hostOrigin
163
+ );
136
164
  }
137
165
  function isEnvelope(m) {
138
166
  if (typeof m !== "object" || m === null) return false;
@@ -205,6 +233,54 @@ function bridgeRuntime(g, makeFetch, config) {
205
233
  }
206
234
  }
207
235
  g.addEventListener("message", onMessage);
236
+ function isPersisted(ev) {
237
+ try {
238
+ if (readPersisted) return readPersisted(ev) === true;
239
+ if (typeof ev !== "object" || ev === null) return false;
240
+ const desc = ownDescriptor(ev, "persisted");
241
+ return !!desc && hasOwn(desc, "value") && desc.value === true;
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+ function onPageHide(ev) {
247
+ if (unloaded || isPersisted(ev)) return;
248
+ unloaded = true;
249
+ closed = true;
250
+ if (sendToHost) {
251
+ try {
252
+ sendToHost(
253
+ {
254
+ v: 1,
255
+ ns: NS,
256
+ id: idPrefix + "-unload",
257
+ kind: "evt",
258
+ type: "frame-unload",
259
+ payload: { session },
260
+ frame: frameToken
261
+ },
262
+ hostOrigin
263
+ );
264
+ } catch {
265
+ }
266
+ }
267
+ try {
268
+ stopInterval(readyTimer);
269
+ } catch {
270
+ }
271
+ try {
272
+ pending.forEach(function(entry) {
273
+ try {
274
+ stopTimeout(entry.timer);
275
+ entry.reject(codeError("UNSUPPORTED", "bridge closed"));
276
+ } catch {
277
+ }
278
+ });
279
+ pending.clear();
280
+ } catch {
281
+ }
282
+ }
283
+ g.addEventListener("pagehide", onPageHide, true);
208
284
  let readyAttempts = 0;
209
285
  function sendReady() {
210
286
  readyAttempts += 1;
@@ -218,6 +294,10 @@ function bridgeRuntime(g, makeFetch, config) {
218
294
  sendReady();
219
295
  function request(type, payload) {
220
296
  return new Promise(function(resolve, reject) {
297
+ if (closed) {
298
+ reject(codeError("UNSUPPORTED", "bridge closed"));
299
+ return;
300
+ }
221
301
  const id = nextId();
222
302
  const timer = setTimeout(function() {
223
303
  pending.delete(id);
package/dist/frame.cjs CHANGED
@@ -130,6 +130,20 @@ function bridgeRuntime(g, makeFetch, config) {
130
130
  const session = config.session;
131
131
  const hostOrigin = config.hostOrigin;
132
132
  const parent = g.parent;
133
+ const sendToHost = parent ? parent.postMessage.bind(parent) : void 0;
134
+ const stopInterval = clearInterval;
135
+ const stopTimeout = clearTimeout;
136
+ let readPersisted;
137
+ const ownDescriptor = Object.getOwnPropertyDescriptor;
138
+ const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty);
139
+ try {
140
+ const pte = g.PageTransitionEvent;
141
+ const desc = pte ? Object.getOwnPropertyDescriptor(pte.prototype, "persisted") : void 0;
142
+ if (desc && typeof desc.get === "function")
143
+ readPersisted = Function.prototype.call.bind(desc.get);
144
+ } catch {
145
+ readPersisted = void 0;
146
+ }
133
147
  const pending = /* @__PURE__ */ new Map();
134
148
  const listeners = /* @__PURE__ */ new Map();
135
149
  let init;
@@ -146,6 +160,17 @@ function bridgeRuntime(g, makeFetch, config) {
146
160
  return out;
147
161
  }
148
162
  const idPrefix = randomPrefix();
163
+ function secureToken() {
164
+ if (!g.crypto || typeof g.crypto.getRandomValues !== "function") return "";
165
+ const bytes = new Uint8Array(16);
166
+ g.crypto.getRandomValues(bytes);
167
+ let out = "";
168
+ for (let i = 0; i < bytes.length; i += 1) out += (bytes[i] + 256).toString(16).slice(1);
169
+ return out;
170
+ }
171
+ const frameToken = secureToken();
172
+ let closed = frameToken === "";
173
+ let unloaded = false;
149
174
  function nextId() {
150
175
  seq += 1;
151
176
  return idPrefix + "-" + seq;
@@ -157,9 +182,12 @@ function bridgeRuntime(g, makeFetch, config) {
157
182
  return err;
158
183
  }
159
184
  function post(kind, type, id, payload) {
160
- if (!parent) return;
185
+ if (!sendToHost || closed) return;
161
186
  const body = Object.assign({}, payload, { session });
162
- parent.postMessage({ v: 1, ns: NS, id, kind, type, payload: body }, hostOrigin);
187
+ sendToHost(
188
+ { v: 1, ns: NS, id, kind, type, payload: body, frame: frameToken },
189
+ hostOrigin
190
+ );
163
191
  }
164
192
  function isEnvelope(m) {
165
193
  if (typeof m !== "object" || m === null) return false;
@@ -232,6 +260,54 @@ function bridgeRuntime(g, makeFetch, config) {
232
260
  }
233
261
  }
234
262
  g.addEventListener("message", onMessage);
263
+ function isPersisted(ev) {
264
+ try {
265
+ if (readPersisted) return readPersisted(ev) === true;
266
+ if (typeof ev !== "object" || ev === null) return false;
267
+ const desc = ownDescriptor(ev, "persisted");
268
+ return !!desc && hasOwn(desc, "value") && desc.value === true;
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+ function onPageHide(ev) {
274
+ if (unloaded || isPersisted(ev)) return;
275
+ unloaded = true;
276
+ closed = true;
277
+ if (sendToHost) {
278
+ try {
279
+ sendToHost(
280
+ {
281
+ v: 1,
282
+ ns: NS,
283
+ id: idPrefix + "-unload",
284
+ kind: "evt",
285
+ type: "frame-unload",
286
+ payload: { session },
287
+ frame: frameToken
288
+ },
289
+ hostOrigin
290
+ );
291
+ } catch {
292
+ }
293
+ }
294
+ try {
295
+ stopInterval(readyTimer);
296
+ } catch {
297
+ }
298
+ try {
299
+ pending.forEach(function(entry) {
300
+ try {
301
+ stopTimeout(entry.timer);
302
+ entry.reject(codeError("UNSUPPORTED", "bridge closed"));
303
+ } catch {
304
+ }
305
+ });
306
+ pending.clear();
307
+ } catch {
308
+ }
309
+ }
310
+ g.addEventListener("pagehide", onPageHide, true);
235
311
  let readyAttempts = 0;
236
312
  function sendReady() {
237
313
  readyAttempts += 1;
@@ -245,6 +321,10 @@ function bridgeRuntime(g, makeFetch, config) {
245
321
  sendReady();
246
322
  function request(type, payload) {
247
323
  return new Promise(function(resolve, reject) {
324
+ if (closed) {
325
+ reject(codeError("UNSUPPORTED", "bridge closed"));
326
+ return;
327
+ }
248
328
  const id = nextId();
249
329
  const timer = setTimeout(function() {
250
330
  pending.delete(id);
package/dist/frame.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { g as BridgeInit, j as BridgeRequestType, i as BridgeRequestMap, e as BridgeEventMap } from './messages-9N0GFq5b.cjs';
1
+ import { g as BridgeInit, j as BridgeRequestType, i as BridgeRequestMap, e as BridgeEventMap } from './messages-CRGnyf-6.cjs';
2
2
 
3
3
  /** 프레임 런타임 설정 — srcdoc 부트스트랩이 상수로 넘긴다. */
4
4
  interface BridgeClientConfig {
@@ -28,14 +28,18 @@ interface RuntimeGlobal {
28
28
  parent: {
29
29
  postMessage(message: unknown, targetOrigin: string): void;
30
30
  } | null;
31
- addEventListener(type: 'message', fn: (ev: {
32
- source: unknown;
33
- origin: string;
34
- data: unknown;
35
- }) => void): void;
31
+ /**
32
+ * `message`(호스트 응답·이벤트)와 `pagehide`(frame-unload 전송, capture)에 쓴다.
33
+ * 처리기 인자 모양은 type마다 다르다 — message는 `{ source, origin, data }`, pagehide는 `{ persisted }`.
34
+ */
35
+ addEventListener(type: 'message' | 'pagehide', fn: (ev: never) => void, capture?: boolean): void;
36
36
  crypto?: {
37
37
  getRandomValues<T extends ArrayBufferView | null>(array: T): T;
38
38
  };
39
+ /** pagehide 이벤트 생성자 — `persisted` 원본 접근자를 부트스트랩 때 잡는 데 쓴다(없으면 이벤트 속성을 읽는다). */
40
+ PageTransitionEvent?: {
41
+ readonly prototype: object;
42
+ };
39
43
  __bstage_bridge__?: BridgeClient;
40
44
  }
41
45
  /** `createBridgeFetch`가 요구하는 클라이언트 부분. */
package/dist/frame.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { g as BridgeInit, j as BridgeRequestType, i as BridgeRequestMap, e as BridgeEventMap } from './messages-9N0GFq5b.js';
1
+ import { g as BridgeInit, j as BridgeRequestType, i as BridgeRequestMap, e as BridgeEventMap } from './messages-CRGnyf-6.js';
2
2
 
3
3
  /** 프레임 런타임 설정 — srcdoc 부트스트랩이 상수로 넘긴다. */
4
4
  interface BridgeClientConfig {
@@ -28,14 +28,18 @@ interface RuntimeGlobal {
28
28
  parent: {
29
29
  postMessage(message: unknown, targetOrigin: string): void;
30
30
  } | null;
31
- addEventListener(type: 'message', fn: (ev: {
32
- source: unknown;
33
- origin: string;
34
- data: unknown;
35
- }) => void): void;
31
+ /**
32
+ * `message`(호스트 응답·이벤트)와 `pagehide`(frame-unload 전송, capture)에 쓴다.
33
+ * 처리기 인자 모양은 type마다 다르다 — message는 `{ source, origin, data }`, pagehide는 `{ persisted }`.
34
+ */
35
+ addEventListener(type: 'message' | 'pagehide', fn: (ev: never) => void, capture?: boolean): void;
36
36
  crypto?: {
37
37
  getRandomValues<T extends ArrayBufferView | null>(array: T): T;
38
38
  };
39
+ /** pagehide 이벤트 생성자 — `persisted` 원본 접근자를 부트스트랩 때 잡는 데 쓴다(없으면 이벤트 속성을 읽는다). */
40
+ PageTransitionEvent?: {
41
+ readonly prototype: object;
42
+ };
39
43
  __bstage_bridge__?: BridgeClient;
40
44
  }
41
45
  /** `createBridgeFetch`가 요구하는 클라이언트 부분. */
package/dist/frame.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  bridgeRuntime,
3
3
  createBridgeFetch
4
- } from "./chunk-KQMJY4ZH.js";
4
+ } from "./chunk-N3JID7KQ.js";
5
5
 
6
6
  // src/frame/createBridgeClient.ts
7
7
  var BRIDGE_CONFIG_GLOBAL = "__bstage_bridge_config__";
package/dist/host.cjs CHANGED
@@ -107,6 +107,7 @@ var REQUEST_TYPES = [
107
107
  var HOST_EVENT_TYPES = ["slot.context", "locale", "member", "theme", "visibility"];
108
108
  var READY_EVENT = "ready";
109
109
  var INIT_RESPONSE = "init";
110
+ var FRAME_UNLOAD_EVENT = "frame-unload";
110
111
  var BRIDGE_SURFACES = ["page", "slot"];
111
112
  function isBridgeSurface(value) {
112
113
  return BRIDGE_SURFACES.includes(value);
@@ -844,6 +845,9 @@ function assertOptions(options) {
844
845
  "[bstage-bridge] externalFetchImpl\uC5D0 partnerFetchImpl(\uD32C \uD1A0\uD070 fetch)\uC744 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4"
845
846
  );
846
847
  }
848
+ if (options.onDispose !== void 0 && typeof options.onDispose !== "function") {
849
+ throw new TypeError("[bstage-bridge] onDispose\uB294 \uD568\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4");
850
+ }
847
851
  if (!options.iframe || !options.init) {
848
852
  throw new TypeError("[bstage-bridge] iframe\xB7init\uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
849
853
  }
@@ -866,6 +870,8 @@ function createBridgeHost(options) {
866
870
  let current = buildInit(options.init, hostOrigin, partnerOrigins, policy);
867
871
  let inFlightFetches = 0;
868
872
  let disposed = false;
873
+ let boundFrame;
874
+ let pendingVisibility;
869
875
  let resizeHandle;
870
876
  let pendingHeight;
871
877
  const raf = typeof win.requestAnimationFrame === "function" ? (cb) => win.requestAnimationFrame(cb) : (cb) => setTimeout(cb, 16);
@@ -929,13 +935,44 @@ function createBridgeHost(options) {
929
935
  const env = ev.data;
930
936
  if (!isBridgeEnvelope(env)) return;
931
937
  if (!hasSession(env.payload, session)) return;
938
+ const token = env.frame;
939
+ if (!isValidSession(token) || boundFrame !== void 0 && token !== boundFrame) {
940
+ dispose("frame-mismatch");
941
+ return;
942
+ }
943
+ boundFrame = token;
944
+ if (env.kind === "evt" && env.type === FRAME_UNLOAD_EVENT) {
945
+ dispose("frame-unload");
946
+ return;
947
+ }
932
948
  if (env.kind === "evt" && env.type === READY_EVENT) {
933
949
  if (!readyLimiter.take()) return;
934
950
  post(frame, createEnvelope("res", INIT_RESPONSE, env.id, { ok: true, data: current }));
951
+ flushPendingVisibility(frame);
935
952
  return;
936
953
  }
954
+ flushPendingVisibility(frame);
937
955
  if (env.kind === "req") void handleRequest(frame, env);
938
956
  }
957
+ function flushPendingVisibility(frame) {
958
+ if (pendingVisibility === void 0) return;
959
+ const payload = pendingVisibility;
960
+ pendingVisibility = void 0;
961
+ post(frame, createEnvelope("evt", "visibility", createSession(), payload));
962
+ }
963
+ function dispose(reason) {
964
+ if (disposed) return;
965
+ disposed = true;
966
+ win.removeEventListener("message", onMessage);
967
+ if (resizeHandle !== void 0) cancelRaf(resizeHandle);
968
+ resizeHandle = void 0;
969
+ if (options.onDispose) {
970
+ try {
971
+ options.onDispose(reason);
972
+ } catch {
973
+ }
974
+ }
975
+ }
939
976
  win.addEventListener("message", onMessage);
940
977
  return {
941
978
  send(type, payload) {
@@ -946,15 +983,15 @@ function createBridgeHost(options) {
946
983
  }
947
984
  const clean = sanitizeEvent(type, payload);
948
985
  current = applyEvent(current, type, clean);
986
+ if (boundFrame === void 0) {
987
+ if (type === "visibility") pendingVisibility = clean;
988
+ return;
989
+ }
949
990
  const frame = iframe.contentWindow;
950
991
  if (frame) post(frame, createEnvelope("evt", type, createSession(), clean));
951
992
  },
952
993
  dispose() {
953
- if (disposed) return;
954
- disposed = true;
955
- win.removeEventListener("message", onMessage);
956
- if (resizeHandle !== void 0) cancelRaf(resizeHandle);
957
- resizeHandle = void 0;
994
+ dispose("manual");
958
995
  }
959
996
  };
960
997
  }
@@ -972,6 +1009,20 @@ function bridgeRuntime(g, makeFetch, config) {
972
1009
  const session = config.session;
973
1010
  const hostOrigin = config.hostOrigin;
974
1011
  const parent = g.parent;
1012
+ const sendToHost = parent ? parent.postMessage.bind(parent) : void 0;
1013
+ const stopInterval = clearInterval;
1014
+ const stopTimeout = clearTimeout;
1015
+ let readPersisted;
1016
+ const ownDescriptor = Object.getOwnPropertyDescriptor;
1017
+ const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty);
1018
+ try {
1019
+ const pte = g.PageTransitionEvent;
1020
+ const desc = pte ? Object.getOwnPropertyDescriptor(pte.prototype, "persisted") : void 0;
1021
+ if (desc && typeof desc.get === "function")
1022
+ readPersisted = Function.prototype.call.bind(desc.get);
1023
+ } catch {
1024
+ readPersisted = void 0;
1025
+ }
975
1026
  const pending = /* @__PURE__ */ new Map();
976
1027
  const listeners = /* @__PURE__ */ new Map();
977
1028
  let init;
@@ -988,6 +1039,17 @@ function bridgeRuntime(g, makeFetch, config) {
988
1039
  return out;
989
1040
  }
990
1041
  const idPrefix = randomPrefix();
1042
+ function secureToken() {
1043
+ if (!g.crypto || typeof g.crypto.getRandomValues !== "function") return "";
1044
+ const bytes = new Uint8Array(16);
1045
+ g.crypto.getRandomValues(bytes);
1046
+ let out = "";
1047
+ for (let i = 0; i < bytes.length; i += 1) out += (bytes[i] + 256).toString(16).slice(1);
1048
+ return out;
1049
+ }
1050
+ const frameToken = secureToken();
1051
+ let closed = frameToken === "";
1052
+ let unloaded = false;
991
1053
  function nextId() {
992
1054
  seq += 1;
993
1055
  return idPrefix + "-" + seq;
@@ -999,9 +1061,12 @@ function bridgeRuntime(g, makeFetch, config) {
999
1061
  return err;
1000
1062
  }
1001
1063
  function post(kind, type, id, payload) {
1002
- if (!parent) return;
1064
+ if (!sendToHost || closed) return;
1003
1065
  const body = Object.assign({}, payload, { session });
1004
- parent.postMessage({ v: 1, ns: NS, id, kind, type, payload: body }, hostOrigin);
1066
+ sendToHost(
1067
+ { v: 1, ns: NS, id, kind, type, payload: body, frame: frameToken },
1068
+ hostOrigin
1069
+ );
1005
1070
  }
1006
1071
  function isEnvelope(m) {
1007
1072
  if (typeof m !== "object" || m === null) return false;
@@ -1074,6 +1139,54 @@ function bridgeRuntime(g, makeFetch, config) {
1074
1139
  }
1075
1140
  }
1076
1141
  g.addEventListener("message", onMessage);
1142
+ function isPersisted(ev) {
1143
+ try {
1144
+ if (readPersisted) return readPersisted(ev) === true;
1145
+ if (typeof ev !== "object" || ev === null) return false;
1146
+ const desc = ownDescriptor(ev, "persisted");
1147
+ return !!desc && hasOwn(desc, "value") && desc.value === true;
1148
+ } catch {
1149
+ return false;
1150
+ }
1151
+ }
1152
+ function onPageHide(ev) {
1153
+ if (unloaded || isPersisted(ev)) return;
1154
+ unloaded = true;
1155
+ closed = true;
1156
+ if (sendToHost) {
1157
+ try {
1158
+ sendToHost(
1159
+ {
1160
+ v: 1,
1161
+ ns: NS,
1162
+ id: idPrefix + "-unload",
1163
+ kind: "evt",
1164
+ type: "frame-unload",
1165
+ payload: { session },
1166
+ frame: frameToken
1167
+ },
1168
+ hostOrigin
1169
+ );
1170
+ } catch {
1171
+ }
1172
+ }
1173
+ try {
1174
+ stopInterval(readyTimer);
1175
+ } catch {
1176
+ }
1177
+ try {
1178
+ pending.forEach(function(entry) {
1179
+ try {
1180
+ stopTimeout(entry.timer);
1181
+ entry.reject(codeError("UNSUPPORTED", "bridge closed"));
1182
+ } catch {
1183
+ }
1184
+ });
1185
+ pending.clear();
1186
+ } catch {
1187
+ }
1188
+ }
1189
+ g.addEventListener("pagehide", onPageHide, true);
1077
1190
  let readyAttempts = 0;
1078
1191
  function sendReady() {
1079
1192
  readyAttempts += 1;
@@ -1087,6 +1200,10 @@ function bridgeRuntime(g, makeFetch, config) {
1087
1200
  sendReady();
1088
1201
  function request(type, payload) {
1089
1202
  return new Promise(function(resolve, reject) {
1203
+ if (closed) {
1204
+ reject(codeError("UNSUPPORTED", "bridge closed"));
1205
+ return;
1206
+ }
1090
1207
  const id = nextId();
1091
1208
  const timer = setTimeout(function() {
1092
1209
  pending.delete(id);
package/dist/host.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { f as BridgeHostEventType, e as BridgeEventMap, s as ToastKind, L as LogLevel, l as BridgeSurface, T as ThemeState, S as SlotState, M as MemberState, m as BridgeTranslations } from './messages-9N0GFq5b.cjs';
2
- export { a as BRIDGE_SURFACES, I as IFRAME_ATTRIBUTES, v as isBridgeSurface } from './messages-9N0GFq5b.cjs';
1
+ import { f as BridgeHostEventType, e as BridgeEventMap, t as ToastKind, L as LogLevel, l as BridgeSurface, T as ThemeState, S as SlotState, M as MemberState, m as BridgeTranslations } from './messages-CRGnyf-6.cjs';
2
+ export { a as BRIDGE_SURFACES, I as IFRAME_ATTRIBUTES, w as isBridgeSurface } from './messages-CRGnyf-6.cjs';
3
3
  export { C as CreateSrcdocOptions, c as createNonce, d as createSession, e as createSrcdoc, f as isValidSession } from './createSrcdoc-bOoxttPz.cjs';
4
4
 
5
5
  /** 호스트 fetch 정책. */
@@ -213,11 +213,25 @@ interface CreateBridgeHostOptions {
213
213
  readonly pinnedHeaders?: Readonly<Record<string, string>>;
214
214
  readonly handlers?: BridgeHostHandlers;
215
215
  readonly policy?: Partial<BridgePolicy>;
216
+ /**
217
+ * 호스트가 dispose될 때 한 번 불린다. `frame-unload`(프레임 문서가 내려감)·`frame-mismatch`(토큰이 없거나 다른
218
+ * 메시지 = 같은 창의 다른 문서)는 호스트가 스스로 끊은 경우, `manual`은 `dispose()` 호출이다. 임베더가 iframe을
219
+ * 바로 걷어 내는 데 쓴다. 이 콜백의 예외는 삼킨다.
220
+ */
221
+ readonly onDispose?: (reason: BridgeDisposeReason) => void;
216
222
  }
223
+ /** `onDispose` 사유. */
224
+ type BridgeDisposeReason = 'frame-unload' | 'frame-mismatch' | 'manual';
217
225
  interface BridgeHost {
218
- /** 프레임에 이벤트를 보낸다. 이후 ready에 답하는 init에도 반영된다. */
226
+ /**
227
+ * 프레임에 이벤트를 보낸다. 이후 ready에 답하는 init에도 반영된다.
228
+ * 프레임 토큰이 묶이기(첫 유효 메시지) 전에는 보내지 않고 init에만 반영한다.
229
+ */
219
230
  send<E extends BridgeHostEventType>(type: E, payload: BridgeEventMap[E]): void;
220
- /** 리스너·대기 중인 resize를 정리한다. 이후 메시지는 무시된다. */
231
+ /**
232
+ * 리스너·대기 중인 resize를 정리한다. 이후 메시지는 무시되고, 진행 중이던 요청의 응답도 프레임에 보내지 않는다.
233
+ * 프레임이 `frame-unload`를 보내거나 다른 프레임 토큰이 오면 호스트가 스스로 부른다.
234
+ */
221
235
  dispose(): void;
222
236
  }
223
237
 
@@ -226,7 +240,11 @@ interface BridgeHost {
226
240
  *
227
241
  * - 발신자 확인: `ev.source === iframe.contentWindow`, origin `"null"`(sandbox 프레임), 봉투 형식,
228
242
  * `payload.session === session`이 모두 맞는 메시지만 처리한다.
229
- * - `evt:ready`에 `res:init`으로 답한다(프레임이 재전송하면 매번 최신 init으로).
243
+ * - 세션당 프레임 문서는 하나다: 처음 받은 유효 메시지의 프레임 토큰(`env.frame`)에 세션을 묶고, 토큰이 없거나
244
+ * 다른 메시지(= 같은 contentWindow에 새로 뜬 문서, 예: 템플릿의 자기 내비게이션)가 오면 즉시 dispose한다.
245
+ * 그래서 두 번째 핸드셰이크는 성립하지 않는다.
246
+ * - `evt:frame-unload`(부트스트랩이 pagehide에서 보낸다)를 받으면 즉시 dispose한다.
247
+ * - `evt:ready`에 `res:init`으로 답한다(같은 토큰의 재전송이면 매번 최신 init으로).
230
248
  * - 요청은 fetch 정책(policy.ts)·요청별 상한(validators.ts)을 적용해 처리기 또는 주입 `fetchImpl`로 넘긴다.
231
249
  *
232
250
  * 토큰은 이 함수가 다루지 않는다 — 인증은 `fetchImpl`(플랫폼의 인증 fetch)이 호스트 쪽에서 붙이고,
@@ -259,4 +277,4 @@ interface PathCheck {
259
277
  */
260
278
  declare function checkPath(pathname: string, options?: PathCheckOptions): PathCheck;
261
279
 
262
- export { ALLOWED_METHODS, ALLOWED_REQUEST_HEADERS, type BridgeHost, type BridgeHostHandlers, type BridgeInitInput, type BridgePolicy, BridgeSurface, type CreateBridgeHostOptions, DEFAULT_GATEWAY_DENY_SEGMENTS, DEFAULT_POLICY, type Destination, type DestinationContext, type DestinationKind, type FrameWindow, HOST_ONLY_HEADERS, type HostFetch, type HostWindow, type IframeLike, MAX_DECODE_PASSES, MAX_PARTNER_ORIGINS, type MessageEventLike, type PathCheck, type PathCheckOptions, type PreparedFetch, checkPath, classifyDestination, createBridgeHost, isAllowedGatewayPath, normalizePartnerOrigins, normalizePinnedHeaders, prepareFetch, resolveDestination, resolvePolicy };
280
+ export { ALLOWED_METHODS, ALLOWED_REQUEST_HEADERS, type BridgeDisposeReason, type BridgeHost, type BridgeHostHandlers, type BridgeInitInput, type BridgePolicy, BridgeSurface, type CreateBridgeHostOptions, DEFAULT_GATEWAY_DENY_SEGMENTS, DEFAULT_POLICY, type Destination, type DestinationContext, type DestinationKind, type FrameWindow, HOST_ONLY_HEADERS, type HostFetch, type HostWindow, type IframeLike, MAX_DECODE_PASSES, MAX_PARTNER_ORIGINS, type MessageEventLike, type PathCheck, type PathCheckOptions, type PreparedFetch, checkPath, classifyDestination, createBridgeHost, isAllowedGatewayPath, normalizePartnerOrigins, normalizePinnedHeaders, prepareFetch, resolveDestination, resolvePolicy };
package/dist/host.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { f as BridgeHostEventType, e as BridgeEventMap, s as ToastKind, L as LogLevel, l as BridgeSurface, T as ThemeState, S as SlotState, M as MemberState, m as BridgeTranslations } from './messages-9N0GFq5b.js';
2
- export { a as BRIDGE_SURFACES, I as IFRAME_ATTRIBUTES, v as isBridgeSurface } from './messages-9N0GFq5b.js';
1
+ import { f as BridgeHostEventType, e as BridgeEventMap, t as ToastKind, L as LogLevel, l as BridgeSurface, T as ThemeState, S as SlotState, M as MemberState, m as BridgeTranslations } from './messages-CRGnyf-6.js';
2
+ export { a as BRIDGE_SURFACES, I as IFRAME_ATTRIBUTES, w as isBridgeSurface } from './messages-CRGnyf-6.js';
3
3
  export { C as CreateSrcdocOptions, c as createNonce, d as createSession, e as createSrcdoc, f as isValidSession } from './createSrcdoc-bOoxttPz.js';
4
4
 
5
5
  /** 호스트 fetch 정책. */
@@ -213,11 +213,25 @@ interface CreateBridgeHostOptions {
213
213
  readonly pinnedHeaders?: Readonly<Record<string, string>>;
214
214
  readonly handlers?: BridgeHostHandlers;
215
215
  readonly policy?: Partial<BridgePolicy>;
216
+ /**
217
+ * 호스트가 dispose될 때 한 번 불린다. `frame-unload`(프레임 문서가 내려감)·`frame-mismatch`(토큰이 없거나 다른
218
+ * 메시지 = 같은 창의 다른 문서)는 호스트가 스스로 끊은 경우, `manual`은 `dispose()` 호출이다. 임베더가 iframe을
219
+ * 바로 걷어 내는 데 쓴다. 이 콜백의 예외는 삼킨다.
220
+ */
221
+ readonly onDispose?: (reason: BridgeDisposeReason) => void;
216
222
  }
223
+ /** `onDispose` 사유. */
224
+ type BridgeDisposeReason = 'frame-unload' | 'frame-mismatch' | 'manual';
217
225
  interface BridgeHost {
218
- /** 프레임에 이벤트를 보낸다. 이후 ready에 답하는 init에도 반영된다. */
226
+ /**
227
+ * 프레임에 이벤트를 보낸다. 이후 ready에 답하는 init에도 반영된다.
228
+ * 프레임 토큰이 묶이기(첫 유효 메시지) 전에는 보내지 않고 init에만 반영한다.
229
+ */
219
230
  send<E extends BridgeHostEventType>(type: E, payload: BridgeEventMap[E]): void;
220
- /** 리스너·대기 중인 resize를 정리한다. 이후 메시지는 무시된다. */
231
+ /**
232
+ * 리스너·대기 중인 resize를 정리한다. 이후 메시지는 무시되고, 진행 중이던 요청의 응답도 프레임에 보내지 않는다.
233
+ * 프레임이 `frame-unload`를 보내거나 다른 프레임 토큰이 오면 호스트가 스스로 부른다.
234
+ */
221
235
  dispose(): void;
222
236
  }
223
237
 
@@ -226,7 +240,11 @@ interface BridgeHost {
226
240
  *
227
241
  * - 발신자 확인: `ev.source === iframe.contentWindow`, origin `"null"`(sandbox 프레임), 봉투 형식,
228
242
  * `payload.session === session`이 모두 맞는 메시지만 처리한다.
229
- * - `evt:ready`에 `res:init`으로 답한다(프레임이 재전송하면 매번 최신 init으로).
243
+ * - 세션당 프레임 문서는 하나다: 처음 받은 유효 메시지의 프레임 토큰(`env.frame`)에 세션을 묶고, 토큰이 없거나
244
+ * 다른 메시지(= 같은 contentWindow에 새로 뜬 문서, 예: 템플릿의 자기 내비게이션)가 오면 즉시 dispose한다.
245
+ * 그래서 두 번째 핸드셰이크는 성립하지 않는다.
246
+ * - `evt:frame-unload`(부트스트랩이 pagehide에서 보낸다)를 받으면 즉시 dispose한다.
247
+ * - `evt:ready`에 `res:init`으로 답한다(같은 토큰의 재전송이면 매번 최신 init으로).
230
248
  * - 요청은 fetch 정책(policy.ts)·요청별 상한(validators.ts)을 적용해 처리기 또는 주입 `fetchImpl`로 넘긴다.
231
249
  *
232
250
  * 토큰은 이 함수가 다루지 않는다 — 인증은 `fetchImpl`(플랫폼의 인증 fetch)이 호스트 쪽에서 붙이고,
@@ -259,4 +277,4 @@ interface PathCheck {
259
277
  */
260
278
  declare function checkPath(pathname: string, options?: PathCheckOptions): PathCheck;
261
279
 
262
- export { ALLOWED_METHODS, ALLOWED_REQUEST_HEADERS, type BridgeHost, type BridgeHostHandlers, type BridgeInitInput, type BridgePolicy, BridgeSurface, type CreateBridgeHostOptions, DEFAULT_GATEWAY_DENY_SEGMENTS, DEFAULT_POLICY, type Destination, type DestinationContext, type DestinationKind, type FrameWindow, HOST_ONLY_HEADERS, type HostFetch, type HostWindow, type IframeLike, MAX_DECODE_PASSES, MAX_PARTNER_ORIGINS, type MessageEventLike, type PathCheck, type PathCheckOptions, type PreparedFetch, checkPath, classifyDestination, createBridgeHost, isAllowedGatewayPath, normalizePartnerOrigins, normalizePinnedHeaders, prepareFetch, resolveDestination, resolvePolicy };
280
+ export { ALLOWED_METHODS, ALLOWED_REQUEST_HEADERS, type BridgeDisposeReason, type BridgeHost, type BridgeHostHandlers, type BridgeInitInput, type BridgePolicy, BridgeSurface, type CreateBridgeHostOptions, DEFAULT_GATEWAY_DENY_SEGMENTS, DEFAULT_POLICY, type Destination, type DestinationContext, type DestinationKind, type FrameWindow, HOST_ONLY_HEADERS, type HostFetch, type HostWindow, type IframeLike, MAX_DECODE_PASSES, MAX_PARTNER_ORIGINS, type MessageEventLike, type PathCheck, type PathCheckOptions, type PreparedFetch, checkPath, classifyDestination, createBridgeHost, isAllowedGatewayPath, normalizePartnerOrigins, normalizePinnedHeaders, prepareFetch, resolveDestination, resolvePolicy };
package/dist/host.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  BridgeError,
6
6
  DEFAULT_GATEWAY_DENY_SEGMENTS,
7
7
  DEFAULT_POLICY,
8
+ FRAME_UNLOAD_EVENT,
8
9
  HOST_ONLY_HEADERS,
9
10
  IFRAME_ATTRIBUTES,
10
11
  INIT_RESPONSE,
@@ -31,8 +32,8 @@ import {
31
32
  prepareFetch,
32
33
  resolveDestination,
33
34
  resolvePolicy
34
- } from "./chunk-6LV7HEN4.js";
35
- import "./chunk-KQMJY4ZH.js";
35
+ } from "./chunk-JH3A3Z2A.js";
36
+ import "./chunk-N3JID7KQ.js";
36
37
 
37
38
  // src/host/rateLimiter.ts
38
39
  var DEFAULT_WINDOW_MS = 6e4;
@@ -453,6 +454,9 @@ function assertOptions(options) {
453
454
  "[bstage-bridge] externalFetchImpl\uC5D0 partnerFetchImpl(\uD32C \uD1A0\uD070 fetch)\uC744 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4"
454
455
  );
455
456
  }
457
+ if (options.onDispose !== void 0 && typeof options.onDispose !== "function") {
458
+ throw new TypeError("[bstage-bridge] onDispose\uB294 \uD568\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4");
459
+ }
456
460
  if (!options.iframe || !options.init) {
457
461
  throw new TypeError("[bstage-bridge] iframe\xB7init\uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
458
462
  }
@@ -475,6 +479,8 @@ function createBridgeHost(options) {
475
479
  let current = buildInit(options.init, hostOrigin, partnerOrigins, policy);
476
480
  let inFlightFetches = 0;
477
481
  let disposed = false;
482
+ let boundFrame;
483
+ let pendingVisibility;
478
484
  let resizeHandle;
479
485
  let pendingHeight;
480
486
  const raf = typeof win.requestAnimationFrame === "function" ? (cb) => win.requestAnimationFrame(cb) : (cb) => setTimeout(cb, 16);
@@ -538,13 +544,44 @@ function createBridgeHost(options) {
538
544
  const env = ev.data;
539
545
  if (!isBridgeEnvelope(env)) return;
540
546
  if (!hasSession(env.payload, session)) return;
547
+ const token = env.frame;
548
+ if (!isValidSession(token) || boundFrame !== void 0 && token !== boundFrame) {
549
+ dispose("frame-mismatch");
550
+ return;
551
+ }
552
+ boundFrame = token;
553
+ if (env.kind === "evt" && env.type === FRAME_UNLOAD_EVENT) {
554
+ dispose("frame-unload");
555
+ return;
556
+ }
541
557
  if (env.kind === "evt" && env.type === READY_EVENT) {
542
558
  if (!readyLimiter.take()) return;
543
559
  post(frame, createEnvelope("res", INIT_RESPONSE, env.id, { ok: true, data: current }));
560
+ flushPendingVisibility(frame);
544
561
  return;
545
562
  }
563
+ flushPendingVisibility(frame);
546
564
  if (env.kind === "req") void handleRequest(frame, env);
547
565
  }
566
+ function flushPendingVisibility(frame) {
567
+ if (pendingVisibility === void 0) return;
568
+ const payload = pendingVisibility;
569
+ pendingVisibility = void 0;
570
+ post(frame, createEnvelope("evt", "visibility", createSession(), payload));
571
+ }
572
+ function dispose(reason) {
573
+ if (disposed) return;
574
+ disposed = true;
575
+ win.removeEventListener("message", onMessage);
576
+ if (resizeHandle !== void 0) cancelRaf(resizeHandle);
577
+ resizeHandle = void 0;
578
+ if (options.onDispose) {
579
+ try {
580
+ options.onDispose(reason);
581
+ } catch {
582
+ }
583
+ }
584
+ }
548
585
  win.addEventListener("message", onMessage);
549
586
  return {
550
587
  send(type, payload) {
@@ -555,15 +592,15 @@ function createBridgeHost(options) {
555
592
  }
556
593
  const clean = sanitizeEvent(type, payload);
557
594
  current = applyEvent(current, type, clean);
595
+ if (boundFrame === void 0) {
596
+ if (type === "visibility") pendingVisibility = clean;
597
+ return;
598
+ }
558
599
  const frame = iframe.contentWindow;
559
600
  if (frame) post(frame, createEnvelope("evt", type, createSession(), clean));
560
601
  },
561
602
  dispose() {
562
- if (disposed) return;
563
- disposed = true;
564
- win.removeEventListener("message", onMessage);
565
- if (resizeHandle !== void 0) cancelRaf(resizeHandle);
566
- resizeHandle = void 0;
603
+ dispose("manual");
567
604
  }
568
605
  };
569
606
  }
package/dist/index.cjs CHANGED
@@ -25,6 +25,7 @@ __export(src_exports, {
25
25
  BRIDGE_SURFACES: () => BRIDGE_SURFACES,
26
26
  BRIDGE_VERSION: () => BRIDGE_VERSION,
27
27
  BridgeError: () => BridgeError,
28
+ FRAME_UNLOAD_EVENT: () => FRAME_UNLOAD_EVENT,
28
29
  HOST_EVENT_TYPES: () => HOST_EVENT_TYPES,
29
30
  IFRAME_ATTRIBUTES: () => IFRAME_ATTRIBUTES,
30
31
  INIT_RESPONSE: () => INIT_RESPONSE,
@@ -114,6 +115,7 @@ var REQUEST_TYPES = [
114
115
  var HOST_EVENT_TYPES = ["slot.context", "locale", "member", "theme", "visibility"];
115
116
  var READY_EVENT = "ready";
116
117
  var INIT_RESPONSE = "init";
118
+ var FRAME_UNLOAD_EVENT = "frame-unload";
117
119
  var BRIDGE_SURFACES = ["page", "slot"];
118
120
  function isBridgeSurface(value) {
119
121
  return BRIDGE_SURFACES.includes(value);
@@ -165,6 +167,20 @@ function bridgeRuntime(g, makeFetch, config) {
165
167
  const session = config.session;
166
168
  const hostOrigin = config.hostOrigin;
167
169
  const parent = g.parent;
170
+ const sendToHost = parent ? parent.postMessage.bind(parent) : void 0;
171
+ const stopInterval = clearInterval;
172
+ const stopTimeout = clearTimeout;
173
+ let readPersisted;
174
+ const ownDescriptor = Object.getOwnPropertyDescriptor;
175
+ const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty);
176
+ try {
177
+ const pte = g.PageTransitionEvent;
178
+ const desc = pte ? Object.getOwnPropertyDescriptor(pte.prototype, "persisted") : void 0;
179
+ if (desc && typeof desc.get === "function")
180
+ readPersisted = Function.prototype.call.bind(desc.get);
181
+ } catch {
182
+ readPersisted = void 0;
183
+ }
168
184
  const pending = /* @__PURE__ */ new Map();
169
185
  const listeners = /* @__PURE__ */ new Map();
170
186
  let init;
@@ -181,6 +197,17 @@ function bridgeRuntime(g, makeFetch, config) {
181
197
  return out;
182
198
  }
183
199
  const idPrefix = randomPrefix();
200
+ function secureToken() {
201
+ if (!g.crypto || typeof g.crypto.getRandomValues !== "function") return "";
202
+ const bytes = new Uint8Array(16);
203
+ g.crypto.getRandomValues(bytes);
204
+ let out = "";
205
+ for (let i = 0; i < bytes.length; i += 1) out += (bytes[i] + 256).toString(16).slice(1);
206
+ return out;
207
+ }
208
+ const frameToken = secureToken();
209
+ let closed = frameToken === "";
210
+ let unloaded = false;
184
211
  function nextId() {
185
212
  seq += 1;
186
213
  return idPrefix + "-" + seq;
@@ -192,9 +219,12 @@ function bridgeRuntime(g, makeFetch, config) {
192
219
  return err;
193
220
  }
194
221
  function post(kind, type, id, payload) {
195
- if (!parent) return;
222
+ if (!sendToHost || closed) return;
196
223
  const body = Object.assign({}, payload, { session });
197
- parent.postMessage({ v: 1, ns: NS, id, kind, type, payload: body }, hostOrigin);
224
+ sendToHost(
225
+ { v: 1, ns: NS, id, kind, type, payload: body, frame: frameToken },
226
+ hostOrigin
227
+ );
198
228
  }
199
229
  function isEnvelope(m) {
200
230
  if (typeof m !== "object" || m === null) return false;
@@ -267,6 +297,54 @@ function bridgeRuntime(g, makeFetch, config) {
267
297
  }
268
298
  }
269
299
  g.addEventListener("message", onMessage);
300
+ function isPersisted(ev) {
301
+ try {
302
+ if (readPersisted) return readPersisted(ev) === true;
303
+ if (typeof ev !== "object" || ev === null) return false;
304
+ const desc = ownDescriptor(ev, "persisted");
305
+ return !!desc && hasOwn(desc, "value") && desc.value === true;
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+ function onPageHide(ev) {
311
+ if (unloaded || isPersisted(ev)) return;
312
+ unloaded = true;
313
+ closed = true;
314
+ if (sendToHost) {
315
+ try {
316
+ sendToHost(
317
+ {
318
+ v: 1,
319
+ ns: NS,
320
+ id: idPrefix + "-unload",
321
+ kind: "evt",
322
+ type: "frame-unload",
323
+ payload: { session },
324
+ frame: frameToken
325
+ },
326
+ hostOrigin
327
+ );
328
+ } catch {
329
+ }
330
+ }
331
+ try {
332
+ stopInterval(readyTimer);
333
+ } catch {
334
+ }
335
+ try {
336
+ pending.forEach(function(entry) {
337
+ try {
338
+ stopTimeout(entry.timer);
339
+ entry.reject(codeError("UNSUPPORTED", "bridge closed"));
340
+ } catch {
341
+ }
342
+ });
343
+ pending.clear();
344
+ } catch {
345
+ }
346
+ }
347
+ g.addEventListener("pagehide", onPageHide, true);
270
348
  let readyAttempts = 0;
271
349
  function sendReady() {
272
350
  readyAttempts += 1;
@@ -280,6 +358,10 @@ function bridgeRuntime(g, makeFetch, config) {
280
358
  sendReady();
281
359
  function request(type, payload) {
282
360
  return new Promise(function(resolve, reject) {
361
+ if (closed) {
362
+ reject(codeError("UNSUPPORTED", "bridge closed"));
363
+ return;
364
+ }
283
365
  const id = nextId();
284
366
  const timer = setTimeout(function() {
285
367
  pending.delete(id);
@@ -646,6 +728,7 @@ ${script}
646
728
  BRIDGE_SURFACES,
647
729
  BRIDGE_VERSION,
648
730
  BridgeError,
731
+ FRAME_UNLOAD_EVENT,
649
732
  HOST_EVENT_TYPES,
650
733
  IFRAME_ATTRIBUTES,
651
734
  INIT_RESPONSE,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- export { B as BRIDGE_ERROR_CODES, a as BRIDGE_SURFACES, b as BridgeError, c as BridgeErrorCode, d as BridgeErrorLike, e as BridgeEventMap, f as BridgeHostEventType, g as BridgeInit, h as BridgeLimits, i as BridgeRequestMap, j as BridgeRequestType, k as BridgeResult, l as BridgeSurface, m as BridgeTranslations, F as FetchRequest, n as FetchResult, H as HOST_EVENT_TYPES, I as IFRAME_ATTRIBUTES, o as INIT_RESPONSE, L as LogLevel, M as MemberState, R as READY_EVENT, p as REQUEST_TIMEOUT_MS, q as REQUEST_TYPES, S as SlotState, r as StorageOp, T as ThemeState, s as ToastKind, W as WithSession, t as isBridgeErrorCode, u as isBridgeErrorLike, v as isBridgeSurface, w as isHostEventType, x as isRequestType } from './messages-9N0GFq5b.cjs';
1
+ export { B as BRIDGE_ERROR_CODES, a as BRIDGE_SURFACES, b as BridgeError, c as BridgeErrorCode, d as BridgeErrorLike, e as BridgeEventMap, f as BridgeHostEventType, g as BridgeInit, h as BridgeLimits, i as BridgeRequestMap, j as BridgeRequestType, k as BridgeResult, l as BridgeSurface, m as BridgeTranslations, F as FRAME_UNLOAD_EVENT, n as FetchRequest, o as FetchResult, H as HOST_EVENT_TYPES, I as IFRAME_ATTRIBUTES, p as INIT_RESPONSE, L as LogLevel, M as MemberState, R as READY_EVENT, q as REQUEST_TIMEOUT_MS, r as REQUEST_TYPES, S as SlotState, s as StorageOp, T as ThemeState, t as ToastKind, W as WithSession, u as isBridgeErrorCode, v as isBridgeErrorLike, w as isBridgeSurface, x as isHostEventType, y as isRequestType } from './messages-CRGnyf-6.cjs';
2
2
  export { C as CreateSrcdocOptions, M as MAX_ELEMENT_NAME_LENGTH, a as MAX_MEDIA_ORIGINS, b as buildFrameCsp, c as createNonce, d as createSession, e as createSrcdoc, i as isValidNonce, f as isValidSession } from './createSrcdoc-bOoxttPz.cjs';
3
3
 
4
4
  /** 봉투 버전. 형식이 하위 호환되지 않게 바뀔 때만 올린다. */
@@ -19,6 +19,11 @@ interface BridgeEnvelope<P = unknown> {
19
19
  readonly kind: EnvelopeKind;
20
20
  readonly type: string;
21
21
  readonly payload: P;
22
+ /**
23
+ * 프레임 → 호스트 봉투에만 붙는 프레임 토큰(소문자 hex 32자). 프레임 런타임이 문서마다 새로 만들고
24
+ * 호스트는 처음 본 값에 세션을 묶는다. 호스트 → 프레임 봉투에는 없다.
25
+ */
26
+ readonly frame?: string;
22
27
  }
23
28
  /**
24
29
  * 값이 브리지 봉투인지 검사한다. 모양만 본다 — 발신자 확인(source·session)은 호출자 몫이다.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { B as BRIDGE_ERROR_CODES, a as BRIDGE_SURFACES, b as BridgeError, c as BridgeErrorCode, d as BridgeErrorLike, e as BridgeEventMap, f as BridgeHostEventType, g as BridgeInit, h as BridgeLimits, i as BridgeRequestMap, j as BridgeRequestType, k as BridgeResult, l as BridgeSurface, m as BridgeTranslations, F as FetchRequest, n as FetchResult, H as HOST_EVENT_TYPES, I as IFRAME_ATTRIBUTES, o as INIT_RESPONSE, L as LogLevel, M as MemberState, R as READY_EVENT, p as REQUEST_TIMEOUT_MS, q as REQUEST_TYPES, S as SlotState, r as StorageOp, T as ThemeState, s as ToastKind, W as WithSession, t as isBridgeErrorCode, u as isBridgeErrorLike, v as isBridgeSurface, w as isHostEventType, x as isRequestType } from './messages-9N0GFq5b.js';
1
+ export { B as BRIDGE_ERROR_CODES, a as BRIDGE_SURFACES, b as BridgeError, c as BridgeErrorCode, d as BridgeErrorLike, e as BridgeEventMap, f as BridgeHostEventType, g as BridgeInit, h as BridgeLimits, i as BridgeRequestMap, j as BridgeRequestType, k as BridgeResult, l as BridgeSurface, m as BridgeTranslations, F as FRAME_UNLOAD_EVENT, n as FetchRequest, o as FetchResult, H as HOST_EVENT_TYPES, I as IFRAME_ATTRIBUTES, p as INIT_RESPONSE, L as LogLevel, M as MemberState, R as READY_EVENT, q as REQUEST_TIMEOUT_MS, r as REQUEST_TYPES, S as SlotState, s as StorageOp, T as ThemeState, t as ToastKind, W as WithSession, u as isBridgeErrorCode, v as isBridgeErrorLike, w as isBridgeSurface, x as isHostEventType, y as isRequestType } from './messages-CRGnyf-6.js';
2
2
  export { C as CreateSrcdocOptions, M as MAX_ELEMENT_NAME_LENGTH, a as MAX_MEDIA_ORIGINS, b as buildFrameCsp, c as createNonce, d as createSession, e as createSrcdoc, i as isValidNonce, f as isValidSession } from './createSrcdoc-bOoxttPz.js';
3
3
 
4
4
  /** 봉투 버전. 형식이 하위 호환되지 않게 바뀔 때만 올린다. */
@@ -19,6 +19,11 @@ interface BridgeEnvelope<P = unknown> {
19
19
  readonly kind: EnvelopeKind;
20
20
  readonly type: string;
21
21
  readonly payload: P;
22
+ /**
23
+ * 프레임 → 호스트 봉투에만 붙는 프레임 토큰(소문자 hex 32자). 프레임 런타임이 문서마다 새로 만들고
24
+ * 호스트는 처음 본 값에 세션을 묶는다. 호스트 → 프레임 봉투에는 없다.
25
+ */
26
+ readonly frame?: string;
22
27
  }
23
28
  /**
24
29
  * 값이 브리지 봉투인지 검사한다. 모양만 본다 — 발신자 확인(source·session)은 호출자 몫이다.
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  BRIDGE_SURFACES,
5
5
  BRIDGE_VERSION,
6
6
  BridgeError,
7
+ FRAME_UNLOAD_EVENT,
7
8
  HOST_EVENT_TYPES,
8
9
  IFRAME_ATTRIBUTES,
9
10
  INIT_RESPONSE,
@@ -28,14 +29,15 @@ import {
28
29
  isRequestType,
29
30
  isValidNonce,
30
31
  isValidSession
31
- } from "./chunk-6LV7HEN4.js";
32
- import "./chunk-KQMJY4ZH.js";
32
+ } from "./chunk-JH3A3Z2A.js";
33
+ import "./chunk-N3JID7KQ.js";
33
34
  export {
34
35
  BRIDGE_ERROR_CODES,
35
36
  BRIDGE_NAMESPACE,
36
37
  BRIDGE_SURFACES,
37
38
  BRIDGE_VERSION,
38
39
  BridgeError,
40
+ FRAME_UNLOAD_EVENT,
39
41
  HOST_EVENT_TYPES,
40
42
  IFRAME_ATTRIBUTES,
41
43
  INIT_RESPONSE,
@@ -28,6 +28,11 @@ type BridgeHostEventType = (typeof HOST_EVENT_TYPES)[number];
28
28
  declare const READY_EVENT = "ready";
29
29
  /** 호스트 → 프레임 핸드셰이크 응답 type. */
30
30
  declare const INIT_RESPONSE = "init";
31
+ /**
32
+ * 프레임 → 호스트 종료 알림 이벤트. 부트스트랩이 `pagehide`(bfcache 제외)에서 동기로 보낸다.
33
+ * 호스트는 받는 즉시 세션을 끊는다(이후 메시지 무시).
34
+ */
35
+ declare const FRAME_UNLOAD_EVENT = "frame-unload";
31
36
  /** 프레임이 보내는 모든 payload에 붙는 세션. */
32
37
  type WithSession<T> = T & {
33
38
  readonly session: string;
@@ -183,4 +188,4 @@ declare const IFRAME_ATTRIBUTES: {
183
188
  declare function isRequestType(value: unknown): value is BridgeRequestType;
184
189
  declare function isHostEventType(value: unknown): value is BridgeHostEventType;
185
190
 
186
- export { BRIDGE_ERROR_CODES as B, type FetchRequest as F, HOST_EVENT_TYPES as H, IFRAME_ATTRIBUTES as I, type LogLevel as L, type MemberState as M, READY_EVENT as R, type SlotState as S, type ThemeState as T, type WithSession as W, BRIDGE_SURFACES as a, BridgeError as b, type BridgeErrorCode as c, type BridgeErrorLike as d, type BridgeEventMap as e, type BridgeHostEventType as f, type BridgeInit as g, type BridgeLimits as h, type BridgeRequestMap as i, type BridgeRequestType as j, type BridgeResult as k, type BridgeSurface as l, type BridgeTranslations as m, type FetchResult as n, INIT_RESPONSE as o, REQUEST_TIMEOUT_MS as p, REQUEST_TYPES as q, type StorageOp as r, type ToastKind as s, isBridgeErrorCode as t, isBridgeErrorLike as u, isBridgeSurface as v, isHostEventType as w, isRequestType as x };
191
+ export { BRIDGE_ERROR_CODES as B, FRAME_UNLOAD_EVENT as F, HOST_EVENT_TYPES as H, IFRAME_ATTRIBUTES as I, type LogLevel as L, type MemberState as M, READY_EVENT as R, type SlotState as S, type ThemeState as T, type WithSession as W, BRIDGE_SURFACES as a, BridgeError as b, type BridgeErrorCode as c, type BridgeErrorLike as d, type BridgeEventMap as e, type BridgeHostEventType as f, type BridgeInit as g, type BridgeLimits as h, type BridgeRequestMap as i, type BridgeRequestType as j, type BridgeResult as k, type BridgeSurface as l, type BridgeTranslations as m, type FetchRequest as n, type FetchResult as o, INIT_RESPONSE as p, REQUEST_TIMEOUT_MS as q, REQUEST_TYPES as r, type StorageOp as s, type ToastKind as t, isBridgeErrorCode as u, isBridgeErrorLike as v, isBridgeSurface as w, isHostEventType as x, isRequestType as y };
@@ -28,6 +28,11 @@ type BridgeHostEventType = (typeof HOST_EVENT_TYPES)[number];
28
28
  declare const READY_EVENT = "ready";
29
29
  /** 호스트 → 프레임 핸드셰이크 응답 type. */
30
30
  declare const INIT_RESPONSE = "init";
31
+ /**
32
+ * 프레임 → 호스트 종료 알림 이벤트. 부트스트랩이 `pagehide`(bfcache 제외)에서 동기로 보낸다.
33
+ * 호스트는 받는 즉시 세션을 끊는다(이후 메시지 무시).
34
+ */
35
+ declare const FRAME_UNLOAD_EVENT = "frame-unload";
31
36
  /** 프레임이 보내는 모든 payload에 붙는 세션. */
32
37
  type WithSession<T> = T & {
33
38
  readonly session: string;
@@ -183,4 +188,4 @@ declare const IFRAME_ATTRIBUTES: {
183
188
  declare function isRequestType(value: unknown): value is BridgeRequestType;
184
189
  declare function isHostEventType(value: unknown): value is BridgeHostEventType;
185
190
 
186
- export { BRIDGE_ERROR_CODES as B, type FetchRequest as F, HOST_EVENT_TYPES as H, IFRAME_ATTRIBUTES as I, type LogLevel as L, type MemberState as M, READY_EVENT as R, type SlotState as S, type ThemeState as T, type WithSession as W, BRIDGE_SURFACES as a, BridgeError as b, type BridgeErrorCode as c, type BridgeErrorLike as d, type BridgeEventMap as e, type BridgeHostEventType as f, type BridgeInit as g, type BridgeLimits as h, type BridgeRequestMap as i, type BridgeRequestType as j, type BridgeResult as k, type BridgeSurface as l, type BridgeTranslations as m, type FetchResult as n, INIT_RESPONSE as o, REQUEST_TIMEOUT_MS as p, REQUEST_TYPES as q, type StorageOp as r, type ToastKind as s, isBridgeErrorCode as t, isBridgeErrorLike as u, isBridgeSurface as v, isHostEventType as w, isRequestType as x };
191
+ export { BRIDGE_ERROR_CODES as B, FRAME_UNLOAD_EVENT as F, HOST_EVENT_TYPES as H, IFRAME_ATTRIBUTES as I, type LogLevel as L, type MemberState as M, READY_EVENT as R, type SlotState as S, type ThemeState as T, type WithSession as W, BRIDGE_SURFACES as a, BridgeError as b, type BridgeErrorCode as c, type BridgeErrorLike as d, type BridgeEventMap as e, type BridgeHostEventType as f, type BridgeInit as g, type BridgeLimits as h, type BridgeRequestMap as i, type BridgeRequestType as j, type BridgeResult as k, type BridgeSurface as l, type BridgeTranslations as m, type FetchRequest as n, type FetchResult as o, INIT_RESPONSE as p, REQUEST_TIMEOUT_MS as q, REQUEST_TYPES as r, type StorageOp as s, type ToastKind as t, isBridgeErrorCode as u, isBridgeErrorLike as v, isBridgeSurface as w, isHostEventType as x, isRequestType as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bstage-sdk/bridge",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "bstage SDK bridge — isolated template iframe protocol, host policy and frame client",
5
5
  "type": "module",
6
6
  "publishConfig": {