@bstage-sdk/bridge 0.11.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,6 +218,29 @@ 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
246
  `createSrcdoc`가 만드는 문서는 CSP로 프레임이 불러올 수 있는 것을 좁힌다. 스크립트는 nonce가 붙은
@@ -246,9 +270,9 @@ connect-src https: wss:; frame-src 'none'; form-action 'none'; base-uri 'none'
246
270
 
247
271
  ### 프레임 네트워크 (항상 적용)
248
272
 
249
- 결정(2026-10-22): 격리 프레임에서 **토큰이 필요한 정해진 API만 브리지**로 보내고(호스트가 팬 토큰을 붙인다),
273
+ 결정(2026-10-21, 2026-10-22에 옵트인 제거): 격리 프레임에서 **토큰이 필요한 정해진 API만 브리지**로 보내고(호스트가 팬 토큰을 붙인다),
250
274
  **그 밖의 API는 템플릿이 프레임 안에서 직접** 부른다. 호스트 옵션은 없다 — `createSrcdoc`로 만든 모든 프레임(포털 미리보기 포함)이
251
- 이 규칙으로 돈다.
275
+ 이 규칙으로 돈다. (0.3.0에서 추가한 `directNetwork` 옵트인과 `buildFrameCsp`의 4번째 인자 `FrameCspOptions`는 다음 minor에서 없앴다. 넘기던 호스트는 인자를 지우면 된다.)
252
276
 
253
277
  동작:
254
278
 
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  bridgeRuntime,
3
3
  createBridgeFetch,
4
- installDirectFetch
5
- } from "./chunk-CSAW3L7P.js";
4
+ installDirectFetch,
5
+ installErrorReport
6
+ } from "./chunk-TPKNGW34.js";
6
7
 
7
8
  // src/protocol/envelope.ts
8
9
  var BRIDGE_VERSION = 1;
@@ -61,7 +62,8 @@ var REQUEST_TYPES = [
61
62
  "resize",
62
63
  "track",
63
64
  "storage",
64
- "log"
65
+ "log",
66
+ "report"
65
67
  ];
66
68
  var HOST_EVENT_TYPES = ["slot.context", "locale", "member", "theme", "visibility"];
67
69
  var READY_EVENT = "ready";
@@ -71,6 +73,12 @@ var BRIDGE_SURFACES = ["page", "slot"];
71
73
  function isBridgeSurface(value) {
72
74
  return BRIDGE_SURFACES.includes(value);
73
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;
74
82
  var IFRAME_ATTRIBUTES = {
75
83
  sandbox: "allow-scripts",
76
84
  allow: "autoplay; fullscreen; picture-in-picture; encrypted-media; clipboard-write",
@@ -402,6 +410,7 @@ function toScriptLiteral(value) {
402
410
 
403
411
  // src/srcdoc/createSrcdoc.ts
404
412
  var MAX_MEDIA_ORIGINS = 20;
413
+ var REPORT_FLUSH_DELAY_MS = 1e3;
405
414
  var MAX_ELEMENT_NAME_LENGTH = 100;
406
415
  var ELEMENT_NAME_RE = /^[a-z][a-z0-9._]*-[a-z0-9._-]*$/;
407
416
  function isValidElementName(value) {
@@ -470,6 +479,16 @@ function bootstrapScript(o) {
470
479
  // 라우터는 항상 심는다. ready.then이 엔트리 로드(아래 client.ready.then)보다 먼저 등록돼야 템플릿보다 먼저 스냅샷을 잡는다.
471
480
  `var installDirectFetch = ${installDirectFetch.toString()};`,
472
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
+ "});",
473
492
  "var lastHeight = -1;",
474
493
  "var scheduled = false;",
475
494
  "function reportHeight() {",
@@ -486,9 +505,12 @@ function bootstrapScript(o) {
486
505
  "}",
487
506
  "client.ready.then(function () {",
488
507
  " var script = document.createElement('script');",
508
+ // crossOrigin은 src보다 먼저 둔다(요청 모드가 src 대입 시점에 정해지는 브라우저가 있다). 주지 않으면 줄 자체가 없다.
509
+ ...o.crossOrigin === "anonymous" ? [" script.crossOrigin = 'anonymous';"] : [],
489
510
  " script.src = ENTRY_URL;",
490
511
  " script.onerror = function () {",
491
512
  " client.request('log', { level: 'error', message: 'template entry failed to load' }).catch(function () {});",
513
+ " reportError('load', 'template entry failed to load', '');",
492
514
  " };",
493
515
  " document.head.appendChild(script);",
494
516
  " customElements.whenDefined(ELEMENT_NAME).then(function () {",
@@ -514,8 +536,12 @@ function createSrcdoc(options) {
514
536
  }
515
537
  if (typeof title !== "string") throw new TypeError("[bstage-bridge] title\uC740 \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4");
516
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
+ }
517
543
  const csp = buildFrameCsp(nonce, assetOrigin, mediaOrigins);
518
- const script = bootstrapScript({ session, hostOrigin, entryUrl, elementName });
544
+ const script = bootstrapScript({ session, hostOrigin, entryUrl, elementName, crossOrigin });
519
545
  if (/<\/script|<!--/i.test(script)) {
520
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");
521
547
  }
@@ -559,6 +585,12 @@ export {
559
585
  FRAME_UNLOAD_EVENT,
560
586
  BRIDGE_SURFACES,
561
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,
562
594
  IFRAME_ATTRIBUTES,
563
595
  isRequestType,
564
596
  isHostEventType,
@@ -583,6 +615,7 @@ export {
583
615
  normalizePinnedHeaders,
584
616
  prepareFetch,
585
617
  MAX_MEDIA_ORIGINS,
618
+ REPORT_FLUSH_DELAY_MS,
586
619
  MAX_ELEMENT_NAME_LENGTH,
587
620
  buildFrameCsp,
588
621
  createSrcdoc
@@ -210,6 +210,112 @@ function installDirectFetch(g, client, config) {
210
210
  return bstageFetch;
211
211
  }
212
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
+
213
319
  // src/frame/runtime.ts
214
320
  function bridgeRuntime(g, makeFetch, config) {
215
321
  const NS = "bstage-bridge";
@@ -463,5 +569,6 @@ function bridgeRuntime(g, makeFetch, config) {
463
569
  export {
464
570
  bridgeRuntime,
465
571
  createBridgeFetch,
466
- installDirectFetch
572
+ installDirectFetch,
573
+ installErrorReport
467
574
  };
@@ -25,9 +25,20 @@ 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
  /**
@@ -48,8 +59,8 @@ declare function buildFrameCsp(nonce: string, assetOrigin: string, mediaOrigins?
48
59
  *
49
60
  * iframe 속성은 `IFRAME_ATTRIBUTES`(sandbox="allow-scripts" 등)를 쓴다.
50
61
  *
51
- * @throws TypeError 입력이 형식에 맞지 않을 때(잘못된 nonce·세션·origin·엔트리·요소 이름·mediaOrigins)
62
+ * @throws TypeError 입력이 형식에 맞지 않을 때(잘못된 nonce·세션·origin·엔트리·요소 이름·mediaOrigins·crossOrigin)
52
63
  */
53
64
  declare function createSrcdoc(options: CreateSrcdocOptions): string;
54
65
 
55
- 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,9 +25,20 @@ 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
  /**
@@ -48,8 +59,8 @@ declare function buildFrameCsp(nonce: string, assetOrigin: string, mediaOrigins?
48
59
  *
49
60
  * iframe 속성은 `IFRAME_ATTRIBUTES`(sandbox="allow-scripts" 등)를 쓴다.
50
61
  *
51
- * @throws TypeError 입력이 형식에 맞지 않을 때(잘못된 nonce·세션·origin·엔트리·요소 이름·mediaOrigins)
62
+ * @throws TypeError 입력이 형식에 맞지 않을 때(잘못된 nonce·세션·origin·엔트리·요소 이름·mediaOrigins·crossOrigin)
52
63
  */
53
64
  declare function createSrcdoc(options: CreateSrcdocOptions): string;
54
65
 
55
- 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 };
package/dist/frame.cjs CHANGED
@@ -22,7 +22,8 @@ var frame_exports = {};
22
22
  __export(frame_exports, {
23
23
  createBridgeClient: () => createBridgeClient,
24
24
  createBridgeFetch: () => createBridgeFetch,
25
- installDirectFetch: () => installDirectFetch
25
+ installDirectFetch: () => installDirectFetch,
26
+ installErrorReport: () => installErrorReport
26
27
  });
27
28
  module.exports = __toCommonJS(frame_exports);
28
29
 
@@ -500,9 +501,116 @@ function installDirectFetch(g, client, config) {
500
501
  });
501
502
  return bstageFetch;
502
503
  }
504
+
505
+ // src/frame/errorReport.ts
506
+ function installErrorReport(g, client, config) {
507
+ const schedule = typeof g.setTimeout === "function" ? g.setTimeout : void 0;
508
+ const entries = /* @__PURE__ */ new Map();
509
+ let admitted = 0;
510
+ function toText(value) {
511
+ if (typeof value === "string") return value;
512
+ try {
513
+ return String(value);
514
+ } catch {
515
+ return "";
516
+ }
517
+ }
518
+ function framesOf(stack) {
519
+ const out = [];
520
+ if (typeof stack !== "string") return out;
521
+ const lines = stack.split("\n");
522
+ for (let i = 0; i < lines.length && out.length < config.maxFrames; i += 1) {
523
+ const line = lines[i].trim();
524
+ if (/^at\s/.test(line) || /^[^@]*@(?:[a-z][\w+.-]*:\/\/|\/)\S*:\d+:\d+$/i.test(line) || /^(?:[a-z][\w+.-]*:\/\/|\/)\S*:\d+:\d+$/i.test(line))
525
+ out.push(line.slice(0, config.maxFrameLength));
526
+ }
527
+ return out;
528
+ }
529
+ function send(key) {
530
+ const entry = entries.get(key);
531
+ if (!entry || entry.sent) return;
532
+ entries.set(key, {
533
+ kind: entry.kind,
534
+ message: entry.message,
535
+ frames: entry.frames,
536
+ count: entry.count,
537
+ sent: true
538
+ });
539
+ try {
540
+ client.request("report", {
541
+ kind: entry.kind,
542
+ message: entry.message,
543
+ frames: entry.frames,
544
+ count: entry.count
545
+ }).catch(function() {
546
+ });
547
+ } catch {
548
+ }
549
+ }
550
+ function record(kind, message, stack) {
551
+ try {
552
+ const text = toText(message).slice(0, config.maxMessageLength);
553
+ const frames = framesOf(stack);
554
+ const key = kind + "|" + text.replace(/\d+/g, "") + "|" + (frames[0] || "");
555
+ const entry = entries.get(key);
556
+ if (entry) {
557
+ if (!entry.sent && entry.count < config.maxCount) {
558
+ entries.set(key, {
559
+ kind: entry.kind,
560
+ message: entry.message,
561
+ frames: entry.frames,
562
+ count: entry.count + 1,
563
+ sent: false
564
+ });
565
+ }
566
+ return;
567
+ }
568
+ if (admitted >= config.maxReports) return;
569
+ admitted += 1;
570
+ entries.set(key, { kind, message: text, frames, count: 1, sent: false });
571
+ if (admitted >= config.maxReports) stop();
572
+ if (schedule) {
573
+ schedule(function() {
574
+ send(key);
575
+ }, config.flushDelayMs);
576
+ } else {
577
+ send(key);
578
+ }
579
+ } catch {
580
+ }
581
+ }
582
+ function onError(ev) {
583
+ const err = ev && ev.error;
584
+ if (err && typeof err === "object") {
585
+ record("error", typeof err.message === "string" ? err.message : ev.message, err.stack);
586
+ return;
587
+ }
588
+ const where = ev && typeof ev.filename === "string" && ev.filename !== "" ? "at " + ev.filename + ":" + toText(ev.lineno) + ":" + toText(ev.colno) : "";
589
+ record("error", ev ? ev.message : "", where);
590
+ }
591
+ function onRejection(ev) {
592
+ const reason = ev ? ev.reason : void 0;
593
+ if (reason && typeof reason === "object" && typeof reason.message === "string") {
594
+ record("rejection", reason.message, reason.stack);
595
+ return;
596
+ }
597
+ record("rejection", reason, "");
598
+ }
599
+ function stop() {
600
+ if (typeof g.removeEventListener !== "function") return;
601
+ g.removeEventListener("error", onError);
602
+ g.removeEventListener("unhandledrejection", onRejection);
603
+ }
604
+ if (typeof g.addEventListener === "function") {
605
+ g.addEventListener("error", onError);
606
+ g.addEventListener("unhandledrejection", onRejection);
607
+ }
608
+ return record;
609
+ }
503
610
  // Annotate the CommonJS export names for ESM import in node:
504
611
  0 && (module.exports = {
505
612
  createBridgeClient,
506
613
  createBridgeFetch,
507
- installDirectFetch
614
+ installDirectFetch,
615
+ installErrorReport
508
616
  });
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-BjpZyiLy.cjs';
1
+ import { g as BridgeInit, j as BridgeRequestType, i as BridgeRequestMap, e as BridgeEventMap, z as TemplateReportKind } from './messages-Cdjpvu2S.cjs';
2
2
 
3
3
  /** 프레임 런타임 설정 — srcdoc 부트스트랩이 상수로 넘긴다. */
4
4
  interface BridgeClientConfig {
@@ -114,4 +114,48 @@ type DirectRoute = 'bridge' | 'direct' | 'reject';
114
114
  */
115
115
  declare function installDirectFetch(g: DirectFetchGlobal, client: Pick<BridgeClient, 'fetch' | 'ready'>, config: DirectFetchConfig): typeof globalThis.fetch;
116
116
 
117
- export { type BridgeClient, type BridgeClientConfig, type BridgeRequester, type CreateBridgeClientOptions, type DirectFetchConfig, type DirectFetchGlobal, type DirectRoute, type Unsubscribe, createBridgeClient, createBridgeFetch, installDirectFetch };
117
+ /** `installErrorReport` 설정 — srcdoc 부트스트랩이 프로토콜 상한을 상수로 넘긴다. */
118
+ interface ErrorReportConfig {
119
+ /** 세션당 서로 다른 fingerprint 상한. 닿으면 리스너를 해제한다. */
120
+ readonly maxReports: number;
121
+ readonly maxMessageLength: number;
122
+ readonly maxFrames: number;
123
+ readonly maxFrameLength: number;
124
+ /** 같은 fingerprint 누적 횟수 상한. */
125
+ readonly maxCount: number;
126
+ /** 첫 발생 뒤 보내기까지 기다리는 시간(ms). 그 사이 같은 fingerprint는 count로 묶인다. */
127
+ readonly flushDelayMs: number;
128
+ }
129
+ /** 수집기가 쓰는 전역의 최소 모양 — 테스트에서 가짜 창을 넘길 수 있게 한다. */
130
+ interface ErrorReportGlobal {
131
+ addEventListener?(type: 'error' | 'unhandledrejection', fn: (ev: never) => void): void;
132
+ removeEventListener?(type: 'error' | 'unhandledrejection', fn: (ev: never) => void): void;
133
+ setTimeout?: (fn: () => void, ms: number) => unknown;
134
+ }
135
+ /** 수집기에 직접 넣는 함수 — 엔트리 스크립트 load 실패처럼 이벤트가 아닌 것을 보고할 때 쓴다. */
136
+ type ErrorReportRecord = (kind: TemplateReportKind, message: unknown, stack: unknown) => void;
137
+ /**
138
+ * 격리 프레임의 템플릿 에러 수집기를 설치한다(`createSrcdoc` 부트스트랩이 항상 설치한다).
139
+ *
140
+ * - 전역 `error`·`unhandledrejection`을 듣고, 반환 함수로 엔트리 load 실패(kind `load`)도 받는다.
141
+ * - 정규화: message는 문자열로 바꿔 `maxMessageLength`에서 자르고, 스택은 줄로 나눠 프레임 꼴(`at …`·`f@url`·
142
+ * `…:줄:열`)인 줄만 `maxFrames`개·각 `maxFrameLength`자로 남긴다. ev.error가 없으면(교차 origin "Script error." 등)
143
+ * ev.message와 `filename:lineno:colno`를 쓴다.
144
+ * - fingerprint(kind + 숫자를 뺀 message + 첫 프레임)마다 한 번만 보낸다. 첫 발생 뒤 `flushDelayMs` 동안 같은
145
+ * fingerprint는 count로 묶고(`maxCount`까지), 보낸 뒤의 반복은 버린다.
146
+ * - 서로 다른 fingerprint는 세션당 `maxReports`건까지. 닿으면 리스너를 해제한다.
147
+ *
148
+ * URL·placement는 싣지 않는다 — 귀속은 호스트가 자기가 띄운 템플릿으로 한다. 보내기 실패는 삼킨다.
149
+ *
150
+ * **자기완결 함수다** — `bridgeRuntime`과 함께 srcdoc에 `toString()`으로 심긴다(runtime.ts 주석 참고).
151
+ * 템플릿 코드보다 먼저 돌며 `setTimeout`을 이때 잡아 둔다. 템플릿이 다른 전역을 바꿔 보고가 틀어져도
152
+ * 호스트가 형식을 다시 검사하므로(`validateReport`) 이 수집기는 신뢰 경계가 아니다.
153
+ *
154
+ * @param g 프레임 전역(`globalThis`)
155
+ * @param client 프레임 브리지 클라이언트(`bridgeRuntime` 결과)
156
+ * @param config 프로토콜 상한과 묶음 대기 시간
157
+ * @returns 이벤트 밖의 에러를 넣는 함수
158
+ */
159
+ declare function installErrorReport(g: ErrorReportGlobal, client: Pick<BridgeClient, 'request'>, config: ErrorReportConfig): ErrorReportRecord;
160
+
161
+ export { type BridgeClient, type BridgeClientConfig, type BridgeRequester, type CreateBridgeClientOptions, type DirectFetchConfig, type DirectFetchGlobal, type DirectRoute, type ErrorReportConfig, type ErrorReportGlobal, type ErrorReportRecord, type Unsubscribe, createBridgeClient, createBridgeFetch, installDirectFetch, installErrorReport };
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-BjpZyiLy.js';
1
+ import { g as BridgeInit, j as BridgeRequestType, i as BridgeRequestMap, e as BridgeEventMap, z as TemplateReportKind } from './messages-Cdjpvu2S.js';
2
2
 
3
3
  /** 프레임 런타임 설정 — srcdoc 부트스트랩이 상수로 넘긴다. */
4
4
  interface BridgeClientConfig {
@@ -114,4 +114,48 @@ type DirectRoute = 'bridge' | 'direct' | 'reject';
114
114
  */
115
115
  declare function installDirectFetch(g: DirectFetchGlobal, client: Pick<BridgeClient, 'fetch' | 'ready'>, config: DirectFetchConfig): typeof globalThis.fetch;
116
116
 
117
- export { type BridgeClient, type BridgeClientConfig, type BridgeRequester, type CreateBridgeClientOptions, type DirectFetchConfig, type DirectFetchGlobal, type DirectRoute, type Unsubscribe, createBridgeClient, createBridgeFetch, installDirectFetch };
117
+ /** `installErrorReport` 설정 — srcdoc 부트스트랩이 프로토콜 상한을 상수로 넘긴다. */
118
+ interface ErrorReportConfig {
119
+ /** 세션당 서로 다른 fingerprint 상한. 닿으면 리스너를 해제한다. */
120
+ readonly maxReports: number;
121
+ readonly maxMessageLength: number;
122
+ readonly maxFrames: number;
123
+ readonly maxFrameLength: number;
124
+ /** 같은 fingerprint 누적 횟수 상한. */
125
+ readonly maxCount: number;
126
+ /** 첫 발생 뒤 보내기까지 기다리는 시간(ms). 그 사이 같은 fingerprint는 count로 묶인다. */
127
+ readonly flushDelayMs: number;
128
+ }
129
+ /** 수집기가 쓰는 전역의 최소 모양 — 테스트에서 가짜 창을 넘길 수 있게 한다. */
130
+ interface ErrorReportGlobal {
131
+ addEventListener?(type: 'error' | 'unhandledrejection', fn: (ev: never) => void): void;
132
+ removeEventListener?(type: 'error' | 'unhandledrejection', fn: (ev: never) => void): void;
133
+ setTimeout?: (fn: () => void, ms: number) => unknown;
134
+ }
135
+ /** 수집기에 직접 넣는 함수 — 엔트리 스크립트 load 실패처럼 이벤트가 아닌 것을 보고할 때 쓴다. */
136
+ type ErrorReportRecord = (kind: TemplateReportKind, message: unknown, stack: unknown) => void;
137
+ /**
138
+ * 격리 프레임의 템플릿 에러 수집기를 설치한다(`createSrcdoc` 부트스트랩이 항상 설치한다).
139
+ *
140
+ * - 전역 `error`·`unhandledrejection`을 듣고, 반환 함수로 엔트리 load 실패(kind `load`)도 받는다.
141
+ * - 정규화: message는 문자열로 바꿔 `maxMessageLength`에서 자르고, 스택은 줄로 나눠 프레임 꼴(`at …`·`f@url`·
142
+ * `…:줄:열`)인 줄만 `maxFrames`개·각 `maxFrameLength`자로 남긴다. ev.error가 없으면(교차 origin "Script error." 등)
143
+ * ev.message와 `filename:lineno:colno`를 쓴다.
144
+ * - fingerprint(kind + 숫자를 뺀 message + 첫 프레임)마다 한 번만 보낸다. 첫 발생 뒤 `flushDelayMs` 동안 같은
145
+ * fingerprint는 count로 묶고(`maxCount`까지), 보낸 뒤의 반복은 버린다.
146
+ * - 서로 다른 fingerprint는 세션당 `maxReports`건까지. 닿으면 리스너를 해제한다.
147
+ *
148
+ * URL·placement는 싣지 않는다 — 귀속은 호스트가 자기가 띄운 템플릿으로 한다. 보내기 실패는 삼킨다.
149
+ *
150
+ * **자기완결 함수다** — `bridgeRuntime`과 함께 srcdoc에 `toString()`으로 심긴다(runtime.ts 주석 참고).
151
+ * 템플릿 코드보다 먼저 돌며 `setTimeout`을 이때 잡아 둔다. 템플릿이 다른 전역을 바꿔 보고가 틀어져도
152
+ * 호스트가 형식을 다시 검사하므로(`validateReport`) 이 수집기는 신뢰 경계가 아니다.
153
+ *
154
+ * @param g 프레임 전역(`globalThis`)
155
+ * @param client 프레임 브리지 클라이언트(`bridgeRuntime` 결과)
156
+ * @param config 프로토콜 상한과 묶음 대기 시간
157
+ * @returns 이벤트 밖의 에러를 넣는 함수
158
+ */
159
+ declare function installErrorReport(g: ErrorReportGlobal, client: Pick<BridgeClient, 'request'>, config: ErrorReportConfig): ErrorReportRecord;
160
+
161
+ export { type BridgeClient, type BridgeClientConfig, type BridgeRequester, type CreateBridgeClientOptions, type DirectFetchConfig, type DirectFetchGlobal, type DirectRoute, type ErrorReportConfig, type ErrorReportGlobal, type ErrorReportRecord, type Unsubscribe, createBridgeClient, createBridgeFetch, installDirectFetch, installErrorReport };
package/dist/frame.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  bridgeRuntime,
3
3
  createBridgeFetch,
4
- installDirectFetch
5
- } from "./chunk-CSAW3L7P.js";
4
+ installDirectFetch,
5
+ installErrorReport
6
+ } from "./chunk-TPKNGW34.js";
6
7
 
7
8
  // src/frame/createBridgeClient.ts
8
9
  var BRIDGE_CONFIG_GLOBAL = "__bstage_bridge_config__";
@@ -19,5 +20,6 @@ function createBridgeClient(options = {}) {
19
20
  export {
20
21
  createBridgeClient,
21
22
  createBridgeFetch,
22
- installDirectFetch
23
+ installDirectFetch,
24
+ installErrorReport
23
25
  };