@botim/mp-debug-sdk 0.3.1 → 0.5.2

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/index.d.cts CHANGED
@@ -45,9 +45,11 @@ interface BuiltinHostHooks {
45
45
  /**
46
46
  * Capture a screenshot. Two return shapes are accepted:
47
47
  * - a raw base64 string → assumed PNG (legacy form)
48
- * - `{ data, format }` → format is one of 'png-base64' | 'jpeg-base64'
49
- * so the admin viewer can pick the right MIME for the data URL.
50
- * The SDK enforces a 1 MB base64 cap regardless.
48
+ * - `{ data, format }` → format is one of:
49
+ * 'png-base64' | 'jpeg-base64' raw bitmap, viewer renders as <img>
50
+ * 'rrweb-snapshot' — `data` is JSON the viewer rebuilds
51
+ * 'html-snapshot' — legacy `data` JSON: { html, … }
52
+ * The SDK enforces a 1 MB base64/JSON cap regardless.
51
53
  */
52
54
  screenshot?: () => ScreenshotResult | Promise<ScreenshotResult>;
53
55
  }
@@ -55,10 +57,17 @@ type ScreenshotResult = string | {
55
57
  data: string;
56
58
  format?: 'png-base64' | 'jpeg-base64'
57
59
  /**
58
- * Self-contained HTML+CSS snapshot `data` is a JSON string with
59
- * the shape `{ html, viewport, url, capturedAt }`. Admin viewers
60
- * render `html` inside a sandboxed `<iframe srcdoc>` so the result
61
- * looks pixel-identical to the live page without any pixel encoding.
60
+ * rrweb-snapshot tree, JSON-stringified. `data` parses to:
61
+ * { snapshot, viewport: { w, h }, url, capturedAt }
62
+ * The admin viewer calls `rrwebSnapshot.rebuild(snapshot, …)` into
63
+ * an iframe to re-materialize the page with full shadow DOM /
64
+ * adoptedStyleSheets / cross-origin CSS fidelity.
65
+ */
66
+ | 'rrweb-snapshot'
67
+ /**
68
+ * Legacy self-contained HTML snapshot (pre-0.4). `data` JSON shape
69
+ * is `{ html, viewport, url, capturedAt }`. Kept on the wire for
70
+ * back-compat; admin viewers still know how to render it.
62
71
  */
63
72
  | 'html-snapshot';
64
73
  };
package/dist/index.d.ts CHANGED
@@ -45,9 +45,11 @@ interface BuiltinHostHooks {
45
45
  /**
46
46
  * Capture a screenshot. Two return shapes are accepted:
47
47
  * - a raw base64 string → assumed PNG (legacy form)
48
- * - `{ data, format }` → format is one of 'png-base64' | 'jpeg-base64'
49
- * so the admin viewer can pick the right MIME for the data URL.
50
- * The SDK enforces a 1 MB base64 cap regardless.
48
+ * - `{ data, format }` → format is one of:
49
+ * 'png-base64' | 'jpeg-base64' raw bitmap, viewer renders as <img>
50
+ * 'rrweb-snapshot' — `data` is JSON the viewer rebuilds
51
+ * 'html-snapshot' — legacy `data` JSON: { html, … }
52
+ * The SDK enforces a 1 MB base64/JSON cap regardless.
51
53
  */
52
54
  screenshot?: () => ScreenshotResult | Promise<ScreenshotResult>;
53
55
  }
@@ -55,10 +57,17 @@ type ScreenshotResult = string | {
55
57
  data: string;
56
58
  format?: 'png-base64' | 'jpeg-base64'
57
59
  /**
58
- * Self-contained HTML+CSS snapshot `data` is a JSON string with
59
- * the shape `{ html, viewport, url, capturedAt }`. Admin viewers
60
- * render `html` inside a sandboxed `<iframe srcdoc>` so the result
61
- * looks pixel-identical to the live page without any pixel encoding.
60
+ * rrweb-snapshot tree, JSON-stringified. `data` parses to:
61
+ * { snapshot, viewport: { w, h }, url, capturedAt }
62
+ * The admin viewer calls `rrwebSnapshot.rebuild(snapshot, …)` into
63
+ * an iframe to re-materialize the page with full shadow DOM /
64
+ * adoptedStyleSheets / cross-origin CSS fidelity.
65
+ */
66
+ | 'rrweb-snapshot'
67
+ /**
68
+ * Legacy self-contained HTML snapshot (pre-0.4). `data` JSON shape
69
+ * is `{ html, viewport, url, capturedAt }`. Kept on the wire for
70
+ * back-compat; admin viewers still know how to render it.
62
71
  */
63
72
  | 'html-snapshot';
64
73
  };
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { snapshot } from 'rrweb-snapshot';
2
+
1
3
  // src/types.ts
2
4
  var SCHEMA_VERSION = 2;
3
5
 
@@ -268,7 +270,7 @@ function resolveAgainstEndpoint(url, base) {
268
270
  function detectDeviceInfo(app, override) {
269
271
  const ua = typeof navigator !== "undefined" ? navigator.userAgent : void 0;
270
272
  return {
271
- deviceId: override?.deviceId ?? generateDeviceId(),
273
+ deviceId: override?.deviceId ?? loadOrCreateDeviceId(),
272
274
  platform: override?.platform ?? detectPlatform(ua),
273
275
  osVersion: override?.osVersion,
274
276
  appName: override?.appName ?? app.name,
@@ -284,6 +286,21 @@ function detectPlatform(ua) {
284
286
  if (/Mozilla|Chrome|Safari|Firefox/i.test(ua)) return "web";
285
287
  return "unknown";
286
288
  }
289
+ var DEVICE_ID_STORAGE_KEY = "botim-debug-sdk:device-id";
290
+ function loadOrCreateDeviceId() {
291
+ try {
292
+ const ls = typeof localStorage !== "undefined" ? localStorage : null;
293
+ if (ls) {
294
+ const stored = ls.getItem(DEVICE_ID_STORAGE_KEY);
295
+ if (stored && stored.length > 0) return stored;
296
+ const fresh = generateDeviceId();
297
+ ls.setItem(DEVICE_ID_STORAGE_KEY, fresh);
298
+ return fresh;
299
+ }
300
+ } catch {
301
+ }
302
+ return generateDeviceId();
303
+ }
287
304
  function generateDeviceId() {
288
305
  const c = typeof crypto !== "undefined" ? crypto : void 0;
289
306
  if (c?.randomUUID) return c.randomUUID();
@@ -558,6 +575,11 @@ function wrapFetch(opts) {
558
575
  method,
559
576
  url,
560
577
  status: res.status,
578
+ // statusText carries the human label ("OK", "Not Found"). Pre-HTTP/2
579
+ // responses always have it; HTTP/2+ defines it as empty by spec but
580
+ // most browsers synthesize one from the code, so this is reliable
581
+ // enough to display alongside the status code.
582
+ statusText: res.statusText || void 0,
561
583
  durationMs: Date.now() - start,
562
584
  resHeaders: headersFromResponse(res),
563
585
  resBody
@@ -571,7 +593,14 @@ function wrapFetch(opts) {
571
593
  url,
572
594
  durationMs: Date.now() - start,
573
595
  errorMessage: err instanceof Error ? err.message : String(err),
574
- errorName: err instanceof Error ? err.name : void 0
596
+ errorName: err instanceof Error ? err.name : void 0,
597
+ // Stack from the rejected promise — points into fetch internals
598
+ // and (when present) the call site that issued the request.
599
+ errorStack: err instanceof Error ? err.stack : void 0,
600
+ // undici frequently wraps the real reason in `cause` (e.g.
601
+ // `TypeError: fetch failed` outside, `Error: ECONNREFUSED` inside).
602
+ // Flatten the chain so operators don't have to dig.
603
+ errorCause: collectCauseChain(err)
575
604
  });
576
605
  throw err;
577
606
  }
@@ -580,6 +609,24 @@ function wrapFetch(opts) {
580
609
  target.fetch = original;
581
610
  };
582
611
  }
612
+ function collectCauseChain(err) {
613
+ if (!err || typeof err !== "object") return void 0;
614
+ const lines = [];
615
+ let cur = err.cause;
616
+ const seen = /* @__PURE__ */ new Set();
617
+ while (cur && lines.length < 5) {
618
+ if (seen.has(cur)) break;
619
+ seen.add(cur);
620
+ if (cur instanceof Error) {
621
+ lines.push(`${cur.name}: ${cur.message}`);
622
+ cur = cur.cause;
623
+ } else {
624
+ lines.push(String(cur));
625
+ cur = cur?.cause;
626
+ }
627
+ }
628
+ return lines.length ? lines.join("\n") : void 0;
629
+ }
583
630
  function wrapXHR(opts) {
584
631
  if (typeof XMLHttpRequest === "undefined") return () => {
585
632
  };
@@ -615,6 +662,7 @@ function wrapXHR(opts) {
615
662
  }
616
663
  s.start = Date.now();
617
664
  s.reqBody = typeof body === "string" ? body : void 0;
665
+ const sendSiteStack = captureCallSiteStack();
618
666
  opts.emit({
619
667
  phase: "request",
620
668
  reqId: s.reqId,
@@ -632,25 +680,32 @@ function wrapXHR(opts) {
632
680
  method: s.method,
633
681
  url: s.url,
634
682
  status: this.status,
683
+ // XHR exposes statusText directly; same display purpose as fetch.
684
+ statusText: this.statusText || void 0,
635
685
  durationMs: Date.now() - s.start,
636
686
  resHeaders: headers,
637
687
  resBody
638
688
  });
639
689
  };
640
- const onError = () => {
690
+ const onError = (kind) => () => {
641
691
  opts.emit({
642
692
  phase: "error",
643
693
  reqId: s.reqId,
644
694
  method: s.method,
645
695
  url: s.url,
646
696
  durationMs: Date.now() - s.start,
647
- errorMessage: this.statusText || "xhr error"
697
+ // Distinguish error/timeout/abort in the message — the standard
698
+ // XHR `statusText` is empty for `error` and unhelpful for the
699
+ // others, so we synthesise a clear label.
700
+ errorMessage: this.statusText || `xhr ${kind}`,
701
+ errorName: `XHR${kind[0].toUpperCase()}${kind.slice(1)}`,
702
+ errorStack: sendSiteStack
648
703
  });
649
704
  };
650
705
  this.addEventListener("load", onLoad);
651
- this.addEventListener("error", onError);
652
- this.addEventListener("timeout", onError);
653
- this.addEventListener("abort", onError);
706
+ this.addEventListener("error", onError("error"));
707
+ this.addEventListener("timeout", onError("timeout"));
708
+ this.addEventListener("abort", onError("abort"));
654
709
  return origSend.apply(this, [body]);
655
710
  };
656
711
  return () => {
@@ -659,6 +714,15 @@ function wrapXHR(opts) {
659
714
  proto.setRequestHeader = origSetReqHeader;
660
715
  };
661
716
  }
717
+ function captureCallSiteStack() {
718
+ try {
719
+ throw new Error("xhr-callsite");
720
+ } catch (err) {
721
+ if (!(err instanceof Error) || !err.stack) return void 0;
722
+ const lines = err.stack.split("\n");
723
+ return lines.slice(2).join("\n") || void 0;
724
+ }
725
+ }
662
726
  function parseXhrHeaders(raw) {
663
727
  const out = {};
664
728
  if (!raw) return out;
@@ -751,8 +815,6 @@ var CommandRegistry = class {
751
815
  }
752
816
  }
753
817
  };
754
-
755
- // src/commands/builtins.ts
756
818
  var MAX_DUMP_BYTES = 64 * 1024;
757
819
  var MAX_SCREENSHOT_BYTES = 1024 * 1024;
758
820
  async function defaultDomScreenshot() {
@@ -761,163 +823,107 @@ async function defaultDomScreenshot() {
761
823
  "[@botim/debug-sdk] default screenshot requires a DOM. Provide builtins.screenshot for non-browser runtimes (e.g. native bridge)."
762
824
  );
763
825
  }
826
+ const injected = injectAdoptedStyleSheets();
827
+ let tree;
764
828
  try {
765
- return await captureViaSvgForeignObject();
766
- } catch (err) {
767
- try {
768
- console.warn(
769
- "[@botim/debug-sdk] SVG/canvas screenshot failed, falling back to HTML snapshot:",
770
- err instanceof Error ? err.message : err
771
- );
772
- } catch {
773
- }
774
- return await captureViaHtmlSnapshot();
775
- }
776
- }
777
- var VOID_ELEMENTS = /* @__PURE__ */ new Set([
778
- "area",
779
- "base",
780
- "br",
781
- "col",
782
- "embed",
783
- "hr",
784
- "img",
785
- "input",
786
- "link",
787
- "meta",
788
- "source",
789
- "track",
790
- "wbr"
791
- ]);
792
- var SKIP_TAGS = /* @__PURE__ */ new Set(["script", "noscript", "template"]);
793
- var DEVTOOL_OVERLAY_PREFIXES = ["vite-", "astro-dev-", "next-route-"];
794
- function isDevtoolOverlay(tag) {
795
- return DEVTOOL_OVERLAY_PREFIXES.some((p) => tag.startsWith(p));
796
- }
797
- function escapeText(s) {
798
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
799
- }
800
- function escapeAttr(s) {
801
- return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
802
- }
803
- function serializeComposed(node, opts, depth) {
804
- if (depth > 256) return "";
805
- if (node.nodeType === Node.TEXT_NODE) {
806
- return escapeText(node.textContent ?? "");
807
- }
808
- if (node.nodeType === Node.COMMENT_NODE) return "";
809
- if (node.nodeType !== Node.ELEMENT_NODE) return "";
810
- const el = node;
811
- const tag = el.tagName.toLowerCase();
812
- if (SKIP_TAGS.has(tag)) return "";
813
- if (isDevtoolOverlay(tag)) return "";
814
- if (tag === "slot") {
815
- const slot = el;
816
- let assigned = [];
817
- try {
818
- assigned = slot.assignedNodes({ flatten: true });
819
- } catch {
829
+ tree = snapshot(document, {
830
+ inlineStylesheet: true,
831
+ inlineImages: true,
832
+ recordCanvas: true,
833
+ // Deliberately NOT slimming. SlimDOM drops <link rel="preload">,
834
+ // hidden form metadata, and other "noise" that's actually relevant
835
+ // when reproducing a layout bug.
836
+ slimDOM: false,
837
+ // Don't mask anything by default — debug-relay already runs a
838
+ // top-level redactor on console payloads, and on-screen text is the
839
+ // whole point of capturing a screenshot. Hosts that need PII masking
840
+ // can wire their own builtins.screenshot using rrweb-snapshot's
841
+ // `maskTextSelector` / `maskInputOptions`.
842
+ maskAllInputs: false
843
+ });
844
+ } finally {
845
+ for (const node of injected) {
846
+ try {
847
+ node.remove();
848
+ } catch {
849
+ }
820
850
  }
821
- const source = assigned.length > 0 ? assigned : Array.from(el.childNodes);
822
- return source.map((c) => serializeComposed(c, opts, depth + 1)).join("");
823
851
  }
824
- if (tag === "link" && (el.getAttribute("rel") || "").toLowerCase() === "stylesheet") {
825
- return "";
852
+ if (!tree) {
853
+ throw new Error("[@botim/debug-sdk] rrweb-snapshot returned null tree");
826
854
  }
827
- if (tag === "style") {
828
- return `<style>${el.textContent ?? ""}</style>`;
829
- }
830
- if (tag === "img" && opts.stripCrossOriginImages) {
831
- const src = el.getAttribute("src") || "";
832
- if (/^(https?:|\/\/)/i.test(src)) {
855
+ const payload = {
856
+ snapshot: tree,
857
+ viewport: {
858
+ w: window.innerWidth || document.documentElement.clientWidth || 0,
859
+ h: window.innerHeight || document.documentElement.clientHeight || 0
860
+ },
861
+ url: location.href,
862
+ capturedAt: Date.now()
863
+ };
864
+ return {
865
+ data: JSON.stringify(payload),
866
+ format: "rrweb-snapshot"
867
+ };
868
+ }
869
+ function injectAdoptedStyleSheets() {
870
+ const injected = [];
871
+ const collect = (sheets) => {
872
+ if (!sheets || sheets.length === 0) return "";
873
+ const chunks = [];
874
+ for (const sheet of sheets) {
833
875
  try {
834
- const u = new URL(src, location.href);
835
- if (u.origin !== location.origin) return "";
876
+ const rules = sheet.cssRules;
877
+ for (const rule of Array.from(rules)) chunks.push(rule.cssText);
836
878
  } catch {
837
- return "";
838
879
  }
839
880
  }
840
- }
841
- if (tag === "canvas") return `<canvas></canvas>`;
842
- const attrs = Array.from(el.attributes).filter((a) => !a.name.startsWith("on")).map((a) => ` ${a.name}="${escapeAttr(a.value)}"`).join("");
843
- if (VOID_ELEMENTS.has(tag)) return `<${tag}${attrs}>`;
844
- const childSource = el.shadowRoot ?? el;
845
- const inner = Array.from(childSource.childNodes).map((c) => serializeComposed(c, opts, depth + 1)).join("");
846
- return `<${tag}${attrs}>${inner}</${tag}>`;
847
- }
848
- function readInlineableStyles() {
849
- const chunks = [];
850
- for (const sheet of Array.from(document.styleSheets)) {
851
- try {
852
- const rules = sheet.cssRules;
853
- for (const rule of Array.from(rules)) chunks.push(rule.cssText);
854
- } catch {
881
+ return chunks.join("\n");
882
+ };
883
+ const inject = (parent, css) => {
884
+ if (!css) return;
885
+ const ownerDoc = parent instanceof Document ? parent : parent.ownerDocument;
886
+ if (!ownerDoc) return;
887
+ const style = ownerDoc.createElement("style");
888
+ style.setAttribute("data-botim-adopted", "1");
889
+ style.textContent = css;
890
+ const target = parent instanceof Document ? parent.head ?? parent.documentElement ?? parent.body : parent;
891
+ if (target) {
892
+ target.insertBefore(style, target.firstChild);
893
+ injected.push(style);
855
894
  }
856
- }
857
- return chunks.join("\n");
858
- }
859
- async function captureViaSvgForeignObject() {
860
- const dpr = Math.min(window.devicePixelRatio || 1, 2);
861
- const w = Math.max(1, Math.min(window.innerWidth || document.documentElement.clientWidth || 1024, 2400));
862
- const h = Math.max(1, Math.min(document.documentElement.scrollHeight, 4e3));
863
- const inlinedCss = readInlineableStyles();
864
- const bodyHtml = serializeComposed(document.body, { stripCrossOriginImages: true }, 0);
865
- const htmlStr = `<html xmlns="http://www.w3.org/1999/xhtml"><body>${bodyHtml}</body></html>`;
866
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><foreignObject width="100%" height="100%">` + (inlinedCss ? `<style xmlns="http://www.w3.org/1999/xhtml">${escapeForXml(inlinedCss)}</style>` : "") + htmlStr + `</foreignObject></svg>`;
867
- const blobUrl = URL.createObjectURL(
868
- new Blob([svg], { type: "image/svg+xml;charset=utf-8" })
869
- );
895
+ };
870
896
  try {
871
- const img = await loadImage(blobUrl);
872
- const canvas = document.createElement("canvas");
873
- canvas.width = w * dpr;
874
- canvas.height = h * dpr;
875
- const ctx = canvas.getContext("2d");
876
- if (!ctx) throw new Error("canvas 2D context unavailable");
877
- const pageBg = getComputedStyle(document.body).backgroundColor || "#ffffff";
878
- ctx.fillStyle = pageBg.startsWith("rgba(0, 0, 0, 0)") ? "#ffffff" : pageBg;
879
- ctx.fillRect(0, 0, canvas.width, canvas.height);
880
- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
881
- const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
882
- return { data: dataUrl.split(",")[1], format: "jpeg-base64" };
883
- } finally {
884
- URL.revokeObjectURL(blobUrl);
897
+ inject(
898
+ document,
899
+ collect(document.adoptedStyleSheets)
900
+ );
901
+ } catch {
885
902
  }
886
- }
887
- function loadImage(src) {
888
- return new Promise((resolve, reject) => {
889
- const img = new Image();
890
- img.crossOrigin = "anonymous";
891
- img.onload = () => resolve(img);
892
- img.onerror = () => reject(new Error("failed to render snapshot SVG"));
893
- img.src = src;
894
- });
895
- }
896
- function escapeForXml(s) {
897
- return s.replace(/]]>/g, "]]&gt;").replace(/<\//g, "&lt;/");
898
- }
899
- async function captureViaHtmlSnapshot() {
900
- const inlinedCss = readInlineableStyles();
901
- const bodyHtml = serializeComposed(document.body, { stripCrossOriginImages: false }, 0);
902
- const html = `<!DOCTYPE html>
903
- <html lang="en">
904
- <head>
905
- <meta charset="utf-8">
906
- <meta name="viewport" content="width=device-width,initial-scale=1">
907
- <base href="${escapeAttr(location.href)}">
908
- ` + (inlinedCss ? `<style data-botim-snapshot="1">${inlinedCss}</style>
909
- ` : "") + `</head>
910
- <body>${bodyHtml}</body>
911
- </html>`;
912
- return {
913
- data: JSON.stringify({
914
- html,
915
- viewport: { w: window.innerWidth, h: window.innerHeight },
916
- url: location.href,
917
- capturedAt: Date.now()
918
- }),
919
- format: "html-snapshot"
903
+ const walkRoot = (root) => {
904
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
905
+ let n = walker.currentNode;
906
+ while (n) {
907
+ const el = n;
908
+ const sr = el.shadowRoot;
909
+ if (sr) {
910
+ try {
911
+ inject(
912
+ sr,
913
+ collect(sr.adoptedStyleSheets)
914
+ );
915
+ } catch {
916
+ }
917
+ walkRoot(sr);
918
+ }
919
+ n = walker.nextNode();
920
+ }
920
921
  };
922
+ try {
923
+ walkRoot(document);
924
+ } catch {
925
+ }
926
+ return injected;
921
927
  }
922
928
  function registerBuiltins(registry, hooks = {}) {
923
929
  registry.register("ping", ping);