@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/dist/host.cjs CHANGED
@@ -29,6 +29,7 @@ __export(host_exports, {
29
29
  IFRAME_ATTRIBUTES: () => IFRAME_ATTRIBUTES,
30
30
  MAX_DECODE_PASSES: () => MAX_DECODE_PASSES,
31
31
  MAX_PARTNER_ORIGINS: () => MAX_PARTNER_ORIGINS,
32
+ REPORTS_PER_SESSION: () => REPORTS_PER_SESSION,
32
33
  checkPath: () => checkPath,
33
34
  classifyDestination: () => classifyDestination,
34
35
  createBridgeHost: () => createBridgeHost,
@@ -102,7 +103,8 @@ var REQUEST_TYPES = [
102
103
  "resize",
103
104
  "track",
104
105
  "storage",
105
- "log"
106
+ "log",
107
+ "report"
106
108
  ];
107
109
  var HOST_EVENT_TYPES = ["slot.context", "locale", "member", "theme", "visibility"];
108
110
  var READY_EVENT = "ready";
@@ -112,6 +114,12 @@ var BRIDGE_SURFACES = ["page", "slot"];
112
114
  function isBridgeSurface(value) {
113
115
  return BRIDGE_SURFACES.includes(value);
114
116
  }
117
+ var TEMPLATE_REPORT_KINDS = ["error", "rejection", "load"];
118
+ var REPORT_MESSAGE_MAX_LENGTH = 300;
119
+ var REPORT_MAX_FRAMES = 10;
120
+ var REPORT_FRAME_MAX_LENGTH = 200;
121
+ var REPORT_COUNT_MAX = 100;
122
+ var REPORTS_PER_SESSION = 5;
115
123
  var IFRAME_ATTRIBUTES = {
116
124
  sandbox: "allow-scripts",
117
125
  allow: "autoplay; fullscreen; picture-in-picture; encrypted-media; clipboard-write",
@@ -669,6 +677,32 @@ function validateLog(payload) {
669
677
  if (!known || typeof message !== "string") throw new BridgeError("BAD_REQUEST", "invalid log");
670
678
  return { level: known, message: message.slice(0, LOG_MAX_LENGTH) };
671
679
  }
680
+ function framesCopy(frames) {
681
+ if (!Array.isArray(frames) || frames.length > REPORT_MAX_FRAMES) return null;
682
+ const out = [];
683
+ for (let i = 0; i < frames.length; i += 1) {
684
+ if (!(i in frames)) return null;
685
+ const f = frames[i];
686
+ if (typeof f !== "string" || f.length > REPORT_FRAME_MAX_LENGTH) return null;
687
+ out.push(f);
688
+ }
689
+ return out;
690
+ }
691
+ function validateReport(payload) {
692
+ const kind = TEMPLATE_REPORT_KINDS.find((k) => k === field(payload, "kind"));
693
+ const message = field(payload, "message");
694
+ const frames = framesCopy(field(payload, "frames"));
695
+ const count = field(payload, "count");
696
+ if (!kind || typeof message !== "string" || message.length > REPORT_MESSAGE_MAX_LENGTH || frames === null || typeof count !== "number" || !Number.isInteger(count) || count < 1 || count > REPORT_COUNT_MAX) {
697
+ throw new BridgeError("BAD_REQUEST", "invalid report");
698
+ }
699
+ return Object.freeze({
700
+ kind,
701
+ message,
702
+ frames: Object.freeze(frames),
703
+ count
704
+ });
705
+ }
672
706
 
673
707
  // src/host/createBridgeHost.ts
674
708
  var OPAQUE_ORIGIN = "null";
@@ -806,6 +840,16 @@ async function dispatch(ctx, type, payload) {
806
840
  else writeConsole(level, message);
807
841
  return EMPTY;
808
842
  }
843
+ case "report": {
844
+ const report = validateReport(payload);
845
+ const onReport = ctx.options.onTemplateReport;
846
+ if (!onReport || !ctx.takeReportSlot()) return EMPTY;
847
+ try {
848
+ onReport(report);
849
+ } catch {
850
+ }
851
+ return EMPTY;
852
+ }
809
853
  }
810
854
  }
811
855
  function failure(code, message) {
@@ -849,6 +893,9 @@ function assertOptions(options) {
849
893
  if (options.onDispose !== void 0 && typeof options.onDispose !== "function") {
850
894
  throw new TypeError("[bstage-bridge] onDispose\uB294 \uD568\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4");
851
895
  }
896
+ if (options.onTemplateReport !== void 0 && typeof options.onTemplateReport !== "function") {
897
+ throw new TypeError("[bstage-bridge] onTemplateReport\uB294 \uD568\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4");
898
+ }
852
899
  if (!options.iframe || !options.init) {
853
900
  throw new TypeError("[bstage-bridge] iframe\xB7init\uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
854
901
  }
@@ -870,6 +917,7 @@ function createBridgeHost(options) {
870
917
  const resizeLimiter = createRateLimiter(RESIZE_PER_SECOND, 1e3);
871
918
  let current = buildInit(options.init, hostOrigin, partnerOrigins, policy);
872
919
  let inFlightFetches = 0;
920
+ let reportsTaken = 0;
873
921
  let disposed = false;
874
922
  let boundFrame;
875
923
  let pendingVisibility;
@@ -899,7 +947,12 @@ function createBridgeHost(options) {
899
947
  externalFetchImpl: options.externalFetchImpl ?? defaultUncredentialedFetch,
900
948
  hostOrigin,
901
949
  toastLimiter: createRateLimiter(TOAST_PER_MINUTE),
902
- scheduleResize
950
+ scheduleResize,
951
+ takeReportSlot() {
952
+ if (reportsTaken >= REPORTS_PER_SESSION) return false;
953
+ reportsTaken += 1;
954
+ return true;
955
+ }
903
956
  };
904
957
  function post(target, envelope) {
905
958
  if (disposed) return;
@@ -1459,6 +1512,112 @@ function installDirectFetch(g, client, config) {
1459
1512
  return bstageFetch;
1460
1513
  }
1461
1514
 
1515
+ // src/frame/errorReport.ts
1516
+ function installErrorReport(g, client, config) {
1517
+ const schedule = typeof g.setTimeout === "function" ? g.setTimeout : void 0;
1518
+ const entries = /* @__PURE__ */ new Map();
1519
+ let admitted = 0;
1520
+ function toText(value) {
1521
+ if (typeof value === "string") return value;
1522
+ try {
1523
+ return String(value);
1524
+ } catch {
1525
+ return "";
1526
+ }
1527
+ }
1528
+ function framesOf(stack) {
1529
+ const out = [];
1530
+ if (typeof stack !== "string") return out;
1531
+ const lines = stack.split("\n");
1532
+ for (let i = 0; i < lines.length && out.length < config.maxFrames; i += 1) {
1533
+ const line = lines[i].trim();
1534
+ if (/^at\s/.test(line) || /^[^@]*@(?:[a-z][\w+.-]*:\/\/|\/)\S*:\d+:\d+$/i.test(line) || /^(?:[a-z][\w+.-]*:\/\/|\/)\S*:\d+:\d+$/i.test(line))
1535
+ out.push(line.slice(0, config.maxFrameLength));
1536
+ }
1537
+ return out;
1538
+ }
1539
+ function send(key) {
1540
+ const entry = entries.get(key);
1541
+ if (!entry || entry.sent) return;
1542
+ entries.set(key, {
1543
+ kind: entry.kind,
1544
+ message: entry.message,
1545
+ frames: entry.frames,
1546
+ count: entry.count,
1547
+ sent: true
1548
+ });
1549
+ try {
1550
+ client.request("report", {
1551
+ kind: entry.kind,
1552
+ message: entry.message,
1553
+ frames: entry.frames,
1554
+ count: entry.count
1555
+ }).catch(function() {
1556
+ });
1557
+ } catch {
1558
+ }
1559
+ }
1560
+ function record(kind, message, stack) {
1561
+ try {
1562
+ const text = toText(message).slice(0, config.maxMessageLength);
1563
+ const frames = framesOf(stack);
1564
+ const key = kind + "|" + text.replace(/\d+/g, "") + "|" + (frames[0] || "");
1565
+ const entry = entries.get(key);
1566
+ if (entry) {
1567
+ if (!entry.sent && entry.count < config.maxCount) {
1568
+ entries.set(key, {
1569
+ kind: entry.kind,
1570
+ message: entry.message,
1571
+ frames: entry.frames,
1572
+ count: entry.count + 1,
1573
+ sent: false
1574
+ });
1575
+ }
1576
+ return;
1577
+ }
1578
+ if (admitted >= config.maxReports) return;
1579
+ admitted += 1;
1580
+ entries.set(key, { kind, message: text, frames, count: 1, sent: false });
1581
+ if (admitted >= config.maxReports) stop();
1582
+ if (schedule) {
1583
+ schedule(function() {
1584
+ send(key);
1585
+ }, config.flushDelayMs);
1586
+ } else {
1587
+ send(key);
1588
+ }
1589
+ } catch {
1590
+ }
1591
+ }
1592
+ function onError(ev) {
1593
+ const err = ev && ev.error;
1594
+ if (err && typeof err === "object") {
1595
+ record("error", typeof err.message === "string" ? err.message : ev.message, err.stack);
1596
+ return;
1597
+ }
1598
+ const where = ev && typeof ev.filename === "string" && ev.filename !== "" ? "at " + ev.filename + ":" + toText(ev.lineno) + ":" + toText(ev.colno) : "";
1599
+ record("error", ev ? ev.message : "", where);
1600
+ }
1601
+ function onRejection(ev) {
1602
+ const reason = ev ? ev.reason : void 0;
1603
+ if (reason && typeof reason === "object" && typeof reason.message === "string") {
1604
+ record("rejection", reason.message, reason.stack);
1605
+ return;
1606
+ }
1607
+ record("rejection", reason, "");
1608
+ }
1609
+ function stop() {
1610
+ if (typeof g.removeEventListener !== "function") return;
1611
+ g.removeEventListener("error", onError);
1612
+ g.removeEventListener("unhandledrejection", onRejection);
1613
+ }
1614
+ if (typeof g.addEventListener === "function") {
1615
+ g.addEventListener("error", onError);
1616
+ g.addEventListener("unhandledrejection", onRejection);
1617
+ }
1618
+ return record;
1619
+ }
1620
+
1462
1621
  // src/srcdoc/escape.ts
1463
1622
  var HTML_ESCAPES = {
1464
1623
  "&": "&amp;",
@@ -1476,6 +1635,7 @@ function toScriptLiteral(value) {
1476
1635
 
1477
1636
  // src/srcdoc/createSrcdoc.ts
1478
1637
  var MAX_MEDIA_ORIGINS = 20;
1638
+ var REPORT_FLUSH_DELAY_MS = 1e3;
1479
1639
  var MAX_ELEMENT_NAME_LENGTH = 100;
1480
1640
  var ELEMENT_NAME_RE = /^[a-z][a-z0-9._]*-[a-z0-9._-]*$/;
1481
1641
  function isValidElementName(value) {
@@ -1544,6 +1704,16 @@ function bootstrapScript(o) {
1544
1704
  // 라우터는 항상 심는다. ready.then이 엔트리 로드(아래 client.ready.then)보다 먼저 등록돼야 템플릿보다 먼저 스냅샷을 잡는다.
1545
1705
  `var installDirectFetch = ${installDirectFetch.toString()};`,
1546
1706
  "installDirectFetch(globalThis, client, { hostOrigin: HOST_ORIGIN });",
1707
+ // 에러 수집기는 엔트리(템플릿)보다 먼저 리스너를 건다. 상한은 호스트 검증기와 같은 프로토콜 값이다.
1708
+ `var installErrorReport = ${installErrorReport.toString()};`,
1709
+ "var reportError = installErrorReport(globalThis, client, {",
1710
+ ` maxReports: ${REPORTS_PER_SESSION},`,
1711
+ ` maxMessageLength: ${REPORT_MESSAGE_MAX_LENGTH},`,
1712
+ ` maxFrames: ${REPORT_MAX_FRAMES},`,
1713
+ ` maxFrameLength: ${REPORT_FRAME_MAX_LENGTH},`,
1714
+ ` maxCount: ${REPORT_COUNT_MAX},`,
1715
+ ` flushDelayMs: ${REPORT_FLUSH_DELAY_MS}`,
1716
+ "});",
1547
1717
  "var lastHeight = -1;",
1548
1718
  "var scheduled = false;",
1549
1719
  "function reportHeight() {",
@@ -1560,9 +1730,12 @@ function bootstrapScript(o) {
1560
1730
  "}",
1561
1731
  "client.ready.then(function () {",
1562
1732
  " var script = document.createElement('script');",
1733
+ // crossOrigin은 src보다 먼저 둔다(요청 모드가 src 대입 시점에 정해지는 브라우저가 있다). 주지 않으면 줄 자체가 없다.
1734
+ ...o.crossOrigin === "anonymous" ? [" script.crossOrigin = 'anonymous';"] : [],
1563
1735
  " script.src = ENTRY_URL;",
1564
1736
  " script.onerror = function () {",
1565
1737
  " client.request('log', { level: 'error', message: 'template entry failed to load' }).catch(function () {});",
1738
+ " reportError('load', 'template entry failed to load', '');",
1566
1739
  " };",
1567
1740
  " document.head.appendChild(script);",
1568
1741
  " customElements.whenDefined(ELEMENT_NAME).then(function () {",
@@ -1588,8 +1761,12 @@ function createSrcdoc(options) {
1588
1761
  }
1589
1762
  if (typeof title !== "string") throw new TypeError("[bstage-bridge] title\uC740 \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4");
1590
1763
  const mediaOrigins = normalizeMediaOrigins(options.mediaOrigins);
1764
+ const { crossOrigin } = options;
1765
+ if (crossOrigin !== void 0 && crossOrigin !== "anonymous") {
1766
+ throw new TypeError("[bstage-bridge] crossOrigin\uC740 'anonymous'\uB9CC \uBC1B\uC2B5\uB2C8\uB2E4");
1767
+ }
1591
1768
  const csp = buildFrameCsp(nonce, assetOrigin, mediaOrigins);
1592
- const script = bootstrapScript({ session, hostOrigin, entryUrl, elementName });
1769
+ const script = bootstrapScript({ session, hostOrigin, entryUrl, elementName, crossOrigin });
1593
1770
  if (/<\/script|<!--/i.test(script)) {
1594
1771
  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");
1595
1772
  }
@@ -1623,6 +1800,7 @@ ${script}
1623
1800
  IFRAME_ATTRIBUTES,
1624
1801
  MAX_DECODE_PASSES,
1625
1802
  MAX_PARTNER_ORIGINS,
1803
+ REPORTS_PER_SESSION,
1626
1804
  checkPath,
1627
1805
  classifyDestination,
1628
1806
  createBridgeHost,
package/dist/host.d.cts CHANGED
@@ -1,6 +1,6 @@
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-BjpZyiLy.cjs';
2
- export { a as BRIDGE_SURFACES, I as IFRAME_ATTRIBUTES, w as isBridgeSurface } from './messages-BjpZyiLy.cjs';
3
- export { C as CreateSrcdocOptions, c as createNonce, d as createSession, e as createSrcdoc, f as isValidSession } from './createSrcdoc-DgQyvIjG.cjs';
1
+ import { f as BridgeHostEventType, e as BridgeEventMap, C as ToastKind, L as LogLevel, l as BridgeSurface, A as ThemeState, S as SlotState, M as MemberState, m as BridgeTranslations, y as TemplateReport } from './messages-Cdjpvu2S.cjs';
2
+ export { a as BRIDGE_SURFACES, I as IFRAME_ATTRIBUTES, q as REPORTS_PER_SESSION, z as TemplateReportKind, G as isBridgeSurface } from './messages-Cdjpvu2S.cjs';
3
+ export { C as CreateSrcdocOptions, c as createNonce, d as createSession, e as createSrcdoc, f as isValidSession } from './createSrcdoc-DYZk6dDJ.cjs';
4
4
 
5
5
  /** 호스트 fetch 정책. */
6
6
  interface BridgePolicy {
@@ -219,6 +219,14 @@ interface CreateBridgeHostOptions {
219
219
  * 바로 걷어 내는 데 쓴다. 이 콜백의 예외는 삼킨다.
220
220
  */
221
221
  readonly onDispose?: (reason: BridgeDisposeReason) => void;
222
+ /**
223
+ * 프레임의 `report`(템플릿 에러 보고)를 검증한 뒤 부른다. phase와 무관하게(real 포함) 불린다.
224
+ * 프레임 세션(이 호스트)당 `REPORTS_PER_SESSION`(5)건까지만 부르고 넘치는 보고는 조용히 버린다.
225
+ * 넘기는 값은 `{ kind, message, frames, count }`뿐이다 — 어느 템플릿인지는 프레임이 보낸 값이 아니라
226
+ * 호스트가 띄운 templateUrl로 임베더가 귀속한다. message·frames는 템플릿이 만든 문자열이라 텍스트로만 다루고,
227
+ * 밖으로 보내기 전 스크러빙은 임베더 몫이다. 이 콜백의 예외는 삼킨다.
228
+ */
229
+ readonly onTemplateReport?: (report: TemplateReport) => void;
222
230
  }
223
231
  /** `onDispose` 사유. */
224
232
  type BridgeDisposeReason = 'frame-unload' | 'frame-mismatch' | 'manual';
@@ -277,4 +285,4 @@ interface PathCheck {
277
285
  */
278
286
  declare function checkPath(pathname: string, options?: PathCheckOptions): PathCheck;
279
287
 
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 };
288
+ 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, TemplateReport, checkPath, classifyDestination, createBridgeHost, isAllowedGatewayPath, normalizePartnerOrigins, normalizePinnedHeaders, prepareFetch, resolveDestination, resolvePolicy };
package/dist/host.d.ts CHANGED
@@ -1,6 +1,6 @@
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-BjpZyiLy.js';
2
- export { a as BRIDGE_SURFACES, I as IFRAME_ATTRIBUTES, w as isBridgeSurface } from './messages-BjpZyiLy.js';
3
- export { C as CreateSrcdocOptions, c as createNonce, d as createSession, e as createSrcdoc, f as isValidSession } from './createSrcdoc-DgQyvIjG.js';
1
+ import { f as BridgeHostEventType, e as BridgeEventMap, C as ToastKind, L as LogLevel, l as BridgeSurface, A as ThemeState, S as SlotState, M as MemberState, m as BridgeTranslations, y as TemplateReport } from './messages-Cdjpvu2S.js';
2
+ export { a as BRIDGE_SURFACES, I as IFRAME_ATTRIBUTES, q as REPORTS_PER_SESSION, z as TemplateReportKind, G as isBridgeSurface } from './messages-Cdjpvu2S.js';
3
+ export { C as CreateSrcdocOptions, c as createNonce, d as createSession, e as createSrcdoc, f as isValidSession } from './createSrcdoc-DYZk6dDJ.js';
4
4
 
5
5
  /** 호스트 fetch 정책. */
6
6
  interface BridgePolicy {
@@ -219,6 +219,14 @@ interface CreateBridgeHostOptions {
219
219
  * 바로 걷어 내는 데 쓴다. 이 콜백의 예외는 삼킨다.
220
220
  */
221
221
  readonly onDispose?: (reason: BridgeDisposeReason) => void;
222
+ /**
223
+ * 프레임의 `report`(템플릿 에러 보고)를 검증한 뒤 부른다. phase와 무관하게(real 포함) 불린다.
224
+ * 프레임 세션(이 호스트)당 `REPORTS_PER_SESSION`(5)건까지만 부르고 넘치는 보고는 조용히 버린다.
225
+ * 넘기는 값은 `{ kind, message, frames, count }`뿐이다 — 어느 템플릿인지는 프레임이 보낸 값이 아니라
226
+ * 호스트가 띄운 templateUrl로 임베더가 귀속한다. message·frames는 템플릿이 만든 문자열이라 텍스트로만 다루고,
227
+ * 밖으로 보내기 전 스크러빙은 임베더 몫이다. 이 콜백의 예외는 삼킨다.
228
+ */
229
+ readonly onTemplateReport?: (report: TemplateReport) => void;
222
230
  }
223
231
  /** `onDispose` 사유. */
224
232
  type BridgeDisposeReason = 'frame-unload' | 'frame-mismatch' | 'manual';
@@ -277,4 +285,4 @@ interface PathCheck {
277
285
  */
278
286
  declare function checkPath(pathname: string, options?: PathCheckOptions): PathCheck;
279
287
 
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 };
288
+ 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, TemplateReport, checkPath, classifyDestination, createBridgeHost, isAllowedGatewayPath, normalizePartnerOrigins, normalizePinnedHeaders, prepareFetch, resolveDestination, resolvePolicy };
package/dist/host.js CHANGED
@@ -12,6 +12,12 @@ import {
12
12
  MAX_DECODE_PASSES,
13
13
  MAX_PARTNER_ORIGINS,
14
14
  READY_EVENT,
15
+ REPORTS_PER_SESSION,
16
+ REPORT_COUNT_MAX,
17
+ REPORT_FRAME_MAX_LENGTH,
18
+ REPORT_MAX_FRAMES,
19
+ REPORT_MESSAGE_MAX_LENGTH,
20
+ TEMPLATE_REPORT_KINDS,
15
21
  checkPath,
16
22
  classifyDestination,
17
23
  createEnvelope,
@@ -32,8 +38,8 @@ import {
32
38
  prepareFetch,
33
39
  resolveDestination,
34
40
  resolvePolicy
35
- } from "./chunk-ND2HFYBP.js";
36
- import "./chunk-CSAW3L7P.js";
41
+ } from "./chunk-NXB47KPE.js";
42
+ import "./chunk-TPKNGW34.js";
37
43
 
38
44
  // src/host/rateLimiter.ts
39
45
  var DEFAULT_WINDOW_MS = 6e4;
@@ -278,6 +284,32 @@ function validateLog(payload) {
278
284
  if (!known || typeof message !== "string") throw new BridgeError("BAD_REQUEST", "invalid log");
279
285
  return { level: known, message: message.slice(0, LOG_MAX_LENGTH) };
280
286
  }
287
+ function framesCopy(frames) {
288
+ if (!Array.isArray(frames) || frames.length > REPORT_MAX_FRAMES) return null;
289
+ const out = [];
290
+ for (let i = 0; i < frames.length; i += 1) {
291
+ if (!(i in frames)) return null;
292
+ const f = frames[i];
293
+ if (typeof f !== "string" || f.length > REPORT_FRAME_MAX_LENGTH) return null;
294
+ out.push(f);
295
+ }
296
+ return out;
297
+ }
298
+ function validateReport(payload) {
299
+ const kind = TEMPLATE_REPORT_KINDS.find((k) => k === field(payload, "kind"));
300
+ const message = field(payload, "message");
301
+ const frames = framesCopy(field(payload, "frames"));
302
+ const count = field(payload, "count");
303
+ if (!kind || typeof message !== "string" || message.length > REPORT_MESSAGE_MAX_LENGTH || frames === null || typeof count !== "number" || !Number.isInteger(count) || count < 1 || count > REPORT_COUNT_MAX) {
304
+ throw new BridgeError("BAD_REQUEST", "invalid report");
305
+ }
306
+ return Object.freeze({
307
+ kind,
308
+ message,
309
+ frames: Object.freeze(frames),
310
+ count
311
+ });
312
+ }
281
313
 
282
314
  // src/host/createBridgeHost.ts
283
315
  var OPAQUE_ORIGIN = "null";
@@ -415,6 +447,16 @@ async function dispatch(ctx, type, payload) {
415
447
  else writeConsole(level, message);
416
448
  return EMPTY;
417
449
  }
450
+ case "report": {
451
+ const report = validateReport(payload);
452
+ const onReport = ctx.options.onTemplateReport;
453
+ if (!onReport || !ctx.takeReportSlot()) return EMPTY;
454
+ try {
455
+ onReport(report);
456
+ } catch {
457
+ }
458
+ return EMPTY;
459
+ }
418
460
  }
419
461
  }
420
462
  function failure(code, message) {
@@ -458,6 +500,9 @@ function assertOptions(options) {
458
500
  if (options.onDispose !== void 0 && typeof options.onDispose !== "function") {
459
501
  throw new TypeError("[bstage-bridge] onDispose\uB294 \uD568\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4");
460
502
  }
503
+ if (options.onTemplateReport !== void 0 && typeof options.onTemplateReport !== "function") {
504
+ throw new TypeError("[bstage-bridge] onTemplateReport\uB294 \uD568\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4");
505
+ }
461
506
  if (!options.iframe || !options.init) {
462
507
  throw new TypeError("[bstage-bridge] iframe\xB7init\uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
463
508
  }
@@ -479,6 +524,7 @@ function createBridgeHost(options) {
479
524
  const resizeLimiter = createRateLimiter(RESIZE_PER_SECOND, 1e3);
480
525
  let current = buildInit(options.init, hostOrigin, partnerOrigins, policy);
481
526
  let inFlightFetches = 0;
527
+ let reportsTaken = 0;
482
528
  let disposed = false;
483
529
  let boundFrame;
484
530
  let pendingVisibility;
@@ -508,7 +554,12 @@ function createBridgeHost(options) {
508
554
  externalFetchImpl: options.externalFetchImpl ?? defaultUncredentialedFetch,
509
555
  hostOrigin,
510
556
  toastLimiter: createRateLimiter(TOAST_PER_MINUTE),
511
- scheduleResize
557
+ scheduleResize,
558
+ takeReportSlot() {
559
+ if (reportsTaken >= REPORTS_PER_SESSION) return false;
560
+ reportsTaken += 1;
561
+ return true;
562
+ }
512
563
  };
513
564
  function post(target, envelope) {
514
565
  if (disposed) return;
@@ -615,6 +666,7 @@ export {
615
666
  IFRAME_ATTRIBUTES,
616
667
  MAX_DECODE_PASSES,
617
668
  MAX_PARTNER_ORIGINS,
669
+ REPORTS_PER_SESSION,
618
670
  checkPath,
619
671
  classifyDestination,
620
672
  createBridgeHost,
package/dist/index.cjs CHANGED
@@ -34,8 +34,15 @@ __export(src_exports, {
34
34
  MAX_ENVELOPE_TYPE_LENGTH: () => MAX_ENVELOPE_TYPE_LENGTH,
35
35
  MAX_MEDIA_ORIGINS: () => MAX_MEDIA_ORIGINS,
36
36
  READY_EVENT: () => READY_EVENT,
37
+ REPORTS_PER_SESSION: () => REPORTS_PER_SESSION,
38
+ REPORT_COUNT_MAX: () => REPORT_COUNT_MAX,
39
+ REPORT_FLUSH_DELAY_MS: () => REPORT_FLUSH_DELAY_MS,
40
+ REPORT_FRAME_MAX_LENGTH: () => REPORT_FRAME_MAX_LENGTH,
41
+ REPORT_MAX_FRAMES: () => REPORT_MAX_FRAMES,
42
+ REPORT_MESSAGE_MAX_LENGTH: () => REPORT_MESSAGE_MAX_LENGTH,
37
43
  REQUEST_TIMEOUT_MS: () => REQUEST_TIMEOUT_MS,
38
44
  REQUEST_TYPES: () => REQUEST_TYPES,
45
+ TEMPLATE_REPORT_KINDS: () => TEMPLATE_REPORT_KINDS,
39
46
  buildFrameCsp: () => buildFrameCsp,
40
47
  createEnvelope: () => createEnvelope,
41
48
  createNonce: () => createNonce,
@@ -110,7 +117,8 @@ var REQUEST_TYPES = [
110
117
  "resize",
111
118
  "track",
112
119
  "storage",
113
- "log"
120
+ "log",
121
+ "report"
114
122
  ];
115
123
  var HOST_EVENT_TYPES = ["slot.context", "locale", "member", "theme", "visibility"];
116
124
  var READY_EVENT = "ready";
@@ -120,6 +128,12 @@ var BRIDGE_SURFACES = ["page", "slot"];
120
128
  function isBridgeSurface(value) {
121
129
  return BRIDGE_SURFACES.includes(value);
122
130
  }
131
+ var TEMPLATE_REPORT_KINDS = ["error", "rejection", "load"];
132
+ var REPORT_MESSAGE_MAX_LENGTH = 300;
133
+ var REPORT_MAX_FRAMES = 10;
134
+ var REPORT_FRAME_MAX_LENGTH = 200;
135
+ var REPORT_COUNT_MAX = 100;
136
+ var REPORTS_PER_SESSION = 5;
123
137
  var IFRAME_ATTRIBUTES = {
124
138
  sandbox: "allow-scripts",
125
139
  allow: "autoplay; fullscreen; picture-in-picture; encrypted-media; clipboard-write",
@@ -616,6 +630,112 @@ function installDirectFetch(g, client, config) {
616
630
  return bstageFetch;
617
631
  }
618
632
 
633
+ // src/frame/errorReport.ts
634
+ function installErrorReport(g, client, config) {
635
+ const schedule = typeof g.setTimeout === "function" ? g.setTimeout : void 0;
636
+ const entries = /* @__PURE__ */ new Map();
637
+ let admitted = 0;
638
+ function toText(value) {
639
+ if (typeof value === "string") return value;
640
+ try {
641
+ return String(value);
642
+ } catch {
643
+ return "";
644
+ }
645
+ }
646
+ function framesOf(stack) {
647
+ const out = [];
648
+ if (typeof stack !== "string") return out;
649
+ const lines = stack.split("\n");
650
+ for (let i = 0; i < lines.length && out.length < config.maxFrames; i += 1) {
651
+ const line = lines[i].trim();
652
+ if (/^at\s/.test(line) || /^[^@]*@(?:[a-z][\w+.-]*:\/\/|\/)\S*:\d+:\d+$/i.test(line) || /^(?:[a-z][\w+.-]*:\/\/|\/)\S*:\d+:\d+$/i.test(line))
653
+ out.push(line.slice(0, config.maxFrameLength));
654
+ }
655
+ return out;
656
+ }
657
+ function send(key) {
658
+ const entry = entries.get(key);
659
+ if (!entry || entry.sent) return;
660
+ entries.set(key, {
661
+ kind: entry.kind,
662
+ message: entry.message,
663
+ frames: entry.frames,
664
+ count: entry.count,
665
+ sent: true
666
+ });
667
+ try {
668
+ client.request("report", {
669
+ kind: entry.kind,
670
+ message: entry.message,
671
+ frames: entry.frames,
672
+ count: entry.count
673
+ }).catch(function() {
674
+ });
675
+ } catch {
676
+ }
677
+ }
678
+ function record(kind, message, stack) {
679
+ try {
680
+ const text = toText(message).slice(0, config.maxMessageLength);
681
+ const frames = framesOf(stack);
682
+ const key = kind + "|" + text.replace(/\d+/g, "") + "|" + (frames[0] || "");
683
+ const entry = entries.get(key);
684
+ if (entry) {
685
+ if (!entry.sent && entry.count < config.maxCount) {
686
+ entries.set(key, {
687
+ kind: entry.kind,
688
+ message: entry.message,
689
+ frames: entry.frames,
690
+ count: entry.count + 1,
691
+ sent: false
692
+ });
693
+ }
694
+ return;
695
+ }
696
+ if (admitted >= config.maxReports) return;
697
+ admitted += 1;
698
+ entries.set(key, { kind, message: text, frames, count: 1, sent: false });
699
+ if (admitted >= config.maxReports) stop();
700
+ if (schedule) {
701
+ schedule(function() {
702
+ send(key);
703
+ }, config.flushDelayMs);
704
+ } else {
705
+ send(key);
706
+ }
707
+ } catch {
708
+ }
709
+ }
710
+ function onError(ev) {
711
+ const err = ev && ev.error;
712
+ if (err && typeof err === "object") {
713
+ record("error", typeof err.message === "string" ? err.message : ev.message, err.stack);
714
+ return;
715
+ }
716
+ const where = ev && typeof ev.filename === "string" && ev.filename !== "" ? "at " + ev.filename + ":" + toText(ev.lineno) + ":" + toText(ev.colno) : "";
717
+ record("error", ev ? ev.message : "", where);
718
+ }
719
+ function onRejection(ev) {
720
+ const reason = ev ? ev.reason : void 0;
721
+ if (reason && typeof reason === "object" && typeof reason.message === "string") {
722
+ record("rejection", reason.message, reason.stack);
723
+ return;
724
+ }
725
+ record("rejection", reason, "");
726
+ }
727
+ function stop() {
728
+ if (typeof g.removeEventListener !== "function") return;
729
+ g.removeEventListener("error", onError);
730
+ g.removeEventListener("unhandledrejection", onRejection);
731
+ }
732
+ if (typeof g.addEventListener === "function") {
733
+ g.addEventListener("error", onError);
734
+ g.addEventListener("unhandledrejection", onRejection);
735
+ }
736
+ return record;
737
+ }
738
+
619
739
  // src/srcdoc/escape.ts
620
740
  var HTML_ESCAPES = {
621
741
  "&": "&amp;",
@@ -708,6 +828,7 @@ var LOCAL_HOST_NAMES = Object.freeze([
708
828
 
709
829
  // src/srcdoc/createSrcdoc.ts
710
830
  var MAX_MEDIA_ORIGINS = 20;
831
+ var REPORT_FLUSH_DELAY_MS = 1e3;
711
832
  var MAX_ELEMENT_NAME_LENGTH = 100;
712
833
  var ELEMENT_NAME_RE = /^[a-z][a-z0-9._]*-[a-z0-9._-]*$/;
713
834
  function isValidElementName(value) {
@@ -776,6 +897,16 @@ function bootstrapScript(o) {
776
897
  // 라우터는 항상 심는다. ready.then이 엔트리 로드(아래 client.ready.then)보다 먼저 등록돼야 템플릿보다 먼저 스냅샷을 잡는다.
777
898
  `var installDirectFetch = ${installDirectFetch.toString()};`,
778
899
  "installDirectFetch(globalThis, client, { hostOrigin: HOST_ORIGIN });",
900
+ // 에러 수집기는 엔트리(템플릿)보다 먼저 리스너를 건다. 상한은 호스트 검증기와 같은 프로토콜 값이다.
901
+ `var installErrorReport = ${installErrorReport.toString()};`,
902
+ "var reportError = installErrorReport(globalThis, client, {",
903
+ ` maxReports: ${REPORTS_PER_SESSION},`,
904
+ ` maxMessageLength: ${REPORT_MESSAGE_MAX_LENGTH},`,
905
+ ` maxFrames: ${REPORT_MAX_FRAMES},`,
906
+ ` maxFrameLength: ${REPORT_FRAME_MAX_LENGTH},`,
907
+ ` maxCount: ${REPORT_COUNT_MAX},`,
908
+ ` flushDelayMs: ${REPORT_FLUSH_DELAY_MS}`,
909
+ "});",
779
910
  "var lastHeight = -1;",
780
911
  "var scheduled = false;",
781
912
  "function reportHeight() {",
@@ -792,9 +923,12 @@ function bootstrapScript(o) {
792
923
  "}",
793
924
  "client.ready.then(function () {",
794
925
  " var script = document.createElement('script');",
926
+ // crossOrigin은 src보다 먼저 둔다(요청 모드가 src 대입 시점에 정해지는 브라우저가 있다). 주지 않으면 줄 자체가 없다.
927
+ ...o.crossOrigin === "anonymous" ? [" script.crossOrigin = 'anonymous';"] : [],
795
928
  " script.src = ENTRY_URL;",
796
929
  " script.onerror = function () {",
797
930
  " client.request('log', { level: 'error', message: 'template entry failed to load' }).catch(function () {});",
931
+ " reportError('load', 'template entry failed to load', '');",
798
932
  " };",
799
933
  " document.head.appendChild(script);",
800
934
  " customElements.whenDefined(ELEMENT_NAME).then(function () {",
@@ -820,8 +954,12 @@ function createSrcdoc(options) {
820
954
  }
821
955
  if (typeof title !== "string") throw new TypeError("[bstage-bridge] title\uC740 \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4");
822
956
  const mediaOrigins = normalizeMediaOrigins(options.mediaOrigins);
957
+ const { crossOrigin } = options;
958
+ if (crossOrigin !== void 0 && crossOrigin !== "anonymous") {
959
+ throw new TypeError("[bstage-bridge] crossOrigin\uC740 'anonymous'\uB9CC \uBC1B\uC2B5\uB2C8\uB2E4");
960
+ }
823
961
  const csp = buildFrameCsp(nonce, assetOrigin, mediaOrigins);
824
- const script = bootstrapScript({ session, hostOrigin, entryUrl, elementName });
962
+ const script = bootstrapScript({ session, hostOrigin, entryUrl, elementName, crossOrigin });
825
963
  if (/<\/script|<!--/i.test(script)) {
826
964
  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");
827
965
  }
@@ -860,8 +998,15 @@ ${script}
860
998
  MAX_ENVELOPE_TYPE_LENGTH,
861
999
  MAX_MEDIA_ORIGINS,
862
1000
  READY_EVENT,
1001
+ REPORTS_PER_SESSION,
1002
+ REPORT_COUNT_MAX,
1003
+ REPORT_FLUSH_DELAY_MS,
1004
+ REPORT_FRAME_MAX_LENGTH,
1005
+ REPORT_MAX_FRAMES,
1006
+ REPORT_MESSAGE_MAX_LENGTH,
863
1007
  REQUEST_TIMEOUT_MS,
864
1008
  REQUEST_TYPES,
1009
+ TEMPLATE_REPORT_KINDS,
865
1010
  buildFrameCsp,
866
1011
  createEnvelope,
867
1012
  createNonce,