@mushi-mushi/web 0.8.0 → 0.9.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
@@ -26,6 +26,7 @@ Browser SDK for Mushi Mushi — embeddable bug reporting widget with Shadow DOM
26
26
  - **Privacy controls** (0.9.1+) — `privacy.maskSelectors`, `privacy.blockSelectors`, `privacy.allowUserRemoveScreenshot` for selector-level screenshot redaction and a one-tap "Remove screenshot" button in the panel
27
27
  - **Repro timeline** (0.10+) — auto-captures route changes, clicks, and SDK lifecycle into a normalised `MushiReport.timeline`; pair with `Mushi.setScreen({ name, route, feature })` for screen-level grouping in the admin
28
28
  - **Two-way replies** (0.11+) — the panel ships a "Your reports" view that polls comments authored by the dev team and lets the reporter reply, all signed with HMAC against the public API key (no auth user required)
29
+ - **Passive inventory discovery** (0.12+) — opt-in `capture.discoverInventory` ships throttled, PII-free observations (route template, page title, `[data-testid]` values, recent fetch paths, query-param **keys** only, sha256 of user/session id) to `POST /v1/sdk/discovery`. The Mushi server aggregates them into a 30-day `discovery_observed_inventory` view and Claude Sonnet drafts a first-pass `inventory.yaml` proposal you can accept on `/inventory ▸ Discovery`. See `MushiDiscoverInventoryConfig` in [`@mushi-mushi/core`](../core)
29
30
  - **SDK identity & freshness** (0.8+) — every report ships `sdkPackage` + `sdkVersion`; the widget polls `/v1/sdk/latest-version` and surfaces an outdated banner (configurable via `widget.outdatedBanner`)
30
31
  - **Self-noise filters** (0.7.1+) — internal Mushi requests are tagged with `X-Mushi-Internal` and excluded from network capture + `apiCascade`; configurable `capture.ignoreUrls` and `proactive.apiCascade.ignoreUrls` for host-app endpoints you also don't want counted
31
32
  - **`Mushi.diagnose()`** (0.7.1+) — one-call CSP / runtime-config / capture / widget health check (also runs without an init for pre-install smoke tests)
@@ -283,6 +284,47 @@ The DB-side `report_comments_fanout_to_reporter` trigger creates a
283
284
  `reporter_notifications` row whenever a `visible_to_reporter` admin comment
284
285
  lands, so the unread count stays in sync without polling.
285
286
 
287
+ ### Passive inventory discovery (v2.1)
288
+
289
+ ```typescript
290
+ Mushi.init({
291
+ projectId: 'proj_xxx',
292
+ apiKey: 'mushi_xxx',
293
+ capture: {
294
+ // `true` enables defaults (60s per-route throttle, heuristic
295
+ // route normalisation). Pass an object for fine-grained control.
296
+ discoverInventory: {
297
+ enabled: true,
298
+ throttleMs: 60_000,
299
+ // Optional — your framework's known route templates so we don't
300
+ // have to guess `/practice/abc-123` → `/practice/[id]`.
301
+ routeTemplates: ['/practice/[id]', '/lessons/[slug]'],
302
+ },
303
+ },
304
+ });
305
+ ```
306
+
307
+ Each emission is one row on `POST /v1/sdk/discovery`:
308
+
309
+ ```jsonc
310
+ {
311
+ "route": "/practice/[id]",
312
+ "page_title": "Practice — Glot.it",
313
+ "dom_summary": "…≤200 chars…",
314
+ "testids": ["practice-submit", "practice-hint"],
315
+ "network_paths": ["/api/practice/run", "/rest/v1/answers"],
316
+ "query_param_keys": ["lang"],
317
+ "user_id_hash": "sha256(…)",
318
+ "observed_at": "2026-05-04T12:00:00Z"
319
+ }
320
+ ```
321
+
322
+ Open `/inventory ▸ Discovery` in the admin to watch routes accumulate,
323
+ hit **Generate proposal**, then **Accept** to write the LLM-drafted
324
+ `inventory.yaml` into the project. Nothing else changes about the SDK —
325
+ the discovery channel is independent of the bug-report widget and stays
326
+ quiet under `prefers-reduced-motion` / when the tab is hidden.
327
+
286
328
  ## Test utilities (`./test-utils`)
287
329
 
288
330
  Deterministic Playwright / jsdom helpers, published as a separate
package/dist/index.cjs CHANGED
@@ -2052,6 +2052,17 @@ function createElementSelector() {
2052
2052
  let active = false;
2053
2053
  let overlay = null;
2054
2054
  let resolvePromise = null;
2055
+ function findNearestTestid(el) {
2056
+ let cur = el;
2057
+ let hops = 0;
2058
+ while (cur && hops < 20) {
2059
+ const tid = cur.getAttribute?.("data-testid");
2060
+ if (tid) return tid;
2061
+ cur = cur.parentElement;
2062
+ hops++;
2063
+ }
2064
+ return null;
2065
+ }
2055
2066
  function getXPath(el) {
2056
2067
  const parts = [];
2057
2068
  let current = el;
@@ -2081,7 +2092,13 @@ function createElementSelector() {
2081
2092
  y: Math.round(rect.y),
2082
2093
  width: Math.round(rect.width),
2083
2094
  height: Math.round(rect.height)
2084
- }
2095
+ },
2096
+ // v2 (whitepaper §4.7): the closest ancestor's `data-testid` lets the
2097
+ // server map this report → an Action node in the inventory graph
2098
+ // without a fuzzy NLP guess. We walk to the body so a deeply nested
2099
+ // span inside a button-with-testid still resolves correctly.
2100
+ nearestTestid: findNearestTestid(el) || void 0,
2101
+ route: typeof window !== "undefined" ? window.location.pathname : void 0
2085
2102
  };
2086
2103
  }
2087
2104
  function createOverlay() {
@@ -2260,6 +2277,194 @@ function textSnippet(el) {
2260
2277
  return text ? text.slice(0, 80) : void 0;
2261
2278
  }
2262
2279
 
2280
+ // src/capture/discovery.ts
2281
+ var DEFAULT_THROTTLE_MS = 6e4;
2282
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2283
+ var HEX24_RE = /^[0-9a-f]{20,}$/i;
2284
+ var NUMERIC_RE = /^\d+$/;
2285
+ var SLUG_HASHY_RE = /^[a-z0-9]{16,}$/i;
2286
+ function normalizeSegment(seg) {
2287
+ if (seg.length === 0) return seg;
2288
+ if (UUID_RE.test(seg)) return "[id]";
2289
+ if (HEX24_RE.test(seg)) return "[id]";
2290
+ if (NUMERIC_RE.test(seg)) return "[id]";
2291
+ if (SLUG_HASHY_RE.test(seg) && /\d/.test(seg)) return "[id]";
2292
+ return seg;
2293
+ }
2294
+ function normalizeRoute(pathname, templates) {
2295
+ const clean = pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
2296
+ if (templates?.length) {
2297
+ const matched = matchTemplate(clean, templates);
2298
+ if (matched) return matched;
2299
+ }
2300
+ return "/" + clean.split("/").filter((s) => s.length > 0).map(normalizeSegment).join("/");
2301
+ }
2302
+ function matchTemplate(pathname, templates) {
2303
+ const segs = pathname.split("/").filter((s) => s.length > 0);
2304
+ const sorted = [...templates].sort(
2305
+ (a, b) => b.split("/").length - a.split("/").length
2306
+ );
2307
+ for (const tpl of sorted) {
2308
+ const tplSegs = tpl.split("/").filter((s) => s.length > 0);
2309
+ if (tplSegs.length !== segs.length) continue;
2310
+ let ok = true;
2311
+ for (let i = 0; i < tplSegs.length; i++) {
2312
+ const t = tplSegs[i];
2313
+ const s = segs[i];
2314
+ if (t.startsWith("[") && t.endsWith("]")) continue;
2315
+ if (t.startsWith(":")) continue;
2316
+ if (t === s) continue;
2317
+ ok = false;
2318
+ break;
2319
+ }
2320
+ if (ok) return "/" + tplSegs.join("/");
2321
+ }
2322
+ return null;
2323
+ }
2324
+ function readTestids() {
2325
+ if (typeof document === "undefined") return [];
2326
+ const out = /* @__PURE__ */ new Set();
2327
+ const els = document.querySelectorAll("[data-testid]");
2328
+ for (const el of Array.from(els)) {
2329
+ const v = el.getAttribute("data-testid");
2330
+ if (v && v.length > 0 && v.length < 120) out.add(v);
2331
+ }
2332
+ return Array.from(out).sort();
2333
+ }
2334
+ function readQueryParamKeys() {
2335
+ if (typeof window === "undefined") return [];
2336
+ try {
2337
+ const params = new URLSearchParams(window.location.search);
2338
+ const out = /* @__PURE__ */ new Set();
2339
+ params.forEach((_, key) => out.add(key));
2340
+ return Array.from(out).sort();
2341
+ } catch {
2342
+ return [];
2343
+ }
2344
+ }
2345
+ function readDomSummary() {
2346
+ if (typeof document === "undefined") return null;
2347
+ const trim = (s) => (s ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
2348
+ const h1 = trim(document.querySelector("h1")?.textContent);
2349
+ if (h1) return h1;
2350
+ const title = trim(document.title);
2351
+ if (title) return title;
2352
+ const main = trim(document.querySelector("main")?.textContent);
2353
+ return main || null;
2354
+ }
2355
+ async function hashUserId(input) {
2356
+ if (!input || typeof crypto === "undefined" || !crypto.subtle) return null;
2357
+ try {
2358
+ const data = new TextEncoder().encode(input);
2359
+ const buf = await crypto.subtle.digest("SHA-256", data);
2360
+ const bytes = new Uint8Array(buf);
2361
+ let hex = "";
2362
+ for (let i = 0; i < bytes.length; i++) {
2363
+ hex += bytes[i].toString(16).padStart(2, "0");
2364
+ }
2365
+ return hex;
2366
+ } catch {
2367
+ return null;
2368
+ }
2369
+ }
2370
+ function createDiscoveryCapture(opts) {
2371
+ const {
2372
+ config,
2373
+ getRecentNetworkPaths,
2374
+ getUserId,
2375
+ getSessionId: getSessionId2,
2376
+ onEvent
2377
+ } = opts;
2378
+ const throttleMs = config.throttleMs ?? DEFAULT_THROTTLE_MS;
2379
+ const captureSummary = config.captureDomSummary !== false;
2380
+ const userIdSource = config.userIdSource ?? "auto";
2381
+ const lastEmittedAt = /* @__PURE__ */ new Map();
2382
+ let lastPath = null;
2383
+ let pendingTimer = null;
2384
+ async function emitForCurrent() {
2385
+ if (typeof window === "undefined") return;
2386
+ const route = normalizeRoute(window.location.pathname, config.routeTemplates);
2387
+ const now = Date.now();
2388
+ const last = lastEmittedAt.get(route) ?? 0;
2389
+ if (now - last < throttleMs) return;
2390
+ lastEmittedAt.set(route, now);
2391
+ let userIdInput = null;
2392
+ if (userIdSource === "auto") {
2393
+ userIdInput = getUserId() ?? getSessionId2();
2394
+ } else if (userIdSource === "session-only") {
2395
+ userIdInput = getSessionId2();
2396
+ }
2397
+ const event = {
2398
+ route,
2399
+ page_title: typeof document !== "undefined" ? (document.title || "").slice(0, 300) || null : null,
2400
+ dom_summary: captureSummary ? readDomSummary() : null,
2401
+ testids: readTestids(),
2402
+ network_paths: getRecentNetworkPaths().slice(-50),
2403
+ query_param_keys: readQueryParamKeys(),
2404
+ user_id_hash: await hashUserId(userIdInput),
2405
+ observed_at: (/* @__PURE__ */ new Date()).toISOString()
2406
+ };
2407
+ onEvent(event);
2408
+ }
2409
+ function scheduleEmit() {
2410
+ if (pendingTimer) return;
2411
+ pendingTimer = setTimeout(() => {
2412
+ pendingTimer = null;
2413
+ void emitForCurrent();
2414
+ }, 100);
2415
+ }
2416
+ function onMaybeNavigation() {
2417
+ if (typeof window === "undefined") return;
2418
+ const path = window.location.pathname + window.location.search;
2419
+ if (path === lastPath) return;
2420
+ lastPath = path;
2421
+ scheduleEmit();
2422
+ }
2423
+ if (typeof window === "undefined") {
2424
+ return {
2425
+ destroy: () => void 0,
2426
+ flushNow: () => void 0
2427
+ };
2428
+ }
2429
+ const originalPush = window.history.pushState.bind(window.history);
2430
+ const originalReplace = window.history.replaceState.bind(window.history);
2431
+ const patchedPush = function patched(...args) {
2432
+ const out = originalPush(...args);
2433
+ onMaybeNavigation();
2434
+ return out;
2435
+ };
2436
+ const patchedReplace = function patched(...args) {
2437
+ const out = originalReplace(...args);
2438
+ onMaybeNavigation();
2439
+ return out;
2440
+ };
2441
+ window.history.pushState = patchedPush;
2442
+ window.history.replaceState = patchedReplace;
2443
+ const onPop = () => onMaybeNavigation();
2444
+ window.addEventListener("popstate", onPop);
2445
+ scheduleEmit();
2446
+ return {
2447
+ destroy() {
2448
+ window.removeEventListener("popstate", onPop);
2449
+ if (window.history.pushState === patchedPush) {
2450
+ window.history.pushState = originalPush;
2451
+ }
2452
+ if (window.history.replaceState === patchedReplace) {
2453
+ window.history.replaceState = originalReplace;
2454
+ }
2455
+ if (pendingTimer) {
2456
+ clearTimeout(pendingTimer);
2457
+ pendingTimer = null;
2458
+ }
2459
+ lastEmittedAt.clear();
2460
+ },
2461
+ flushNow() {
2462
+ lastEmittedAt.clear();
2463
+ void emitForCurrent();
2464
+ }
2465
+ };
2466
+ }
2467
+
2263
2468
  // src/sentry.ts
2264
2469
  function getSentryGlobal() {
2265
2470
  try {
@@ -2475,7 +2680,7 @@ function createProactiveManager(config = {}) {
2475
2680
 
2476
2681
  // src/version.ts
2477
2682
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/web";
2478
- var MUSHI_SDK_VERSION = "0.8.0" ;
2683
+ var MUSHI_SDK_VERSION = "0.9.0" ;
2479
2684
 
2480
2685
  // src/mushi.ts
2481
2686
  var instance = null;
@@ -2528,6 +2733,7 @@ function createInstance(config) {
2528
2733
  let perfCap = null;
2529
2734
  let screenshotCap = null;
2530
2735
  let elementSelector = null;
2736
+ let discoveryCap = null;
2531
2737
  const timelineCap = createTimelineCapture();
2532
2738
  let widget;
2533
2739
  function syncCaptureModules() {
@@ -2576,6 +2782,40 @@ function createInstance(config) {
2576
2782
  elementSelector = null;
2577
2783
  pendingElement = null;
2578
2784
  }
2785
+ const discoveryRaw = activeConfig.capture?.discoverInventory;
2786
+ const discoveryConfig = discoveryRaw === true ? {} : discoveryRaw && typeof discoveryRaw === "object" ? discoveryRaw : null;
2787
+ const discoveryEnabled = discoveryConfig != null && discoveryConfig.enabled !== false;
2788
+ if (discoveryEnabled) {
2789
+ discoveryCap?.destroy();
2790
+ discoveryCap = createDiscoveryCapture({
2791
+ config: discoveryConfig,
2792
+ getRecentNetworkPaths: () => {
2793
+ if (!networkCap) return [];
2794
+ return networkCap.getEntries().map((e) => {
2795
+ try {
2796
+ const u = new URL(e.url, typeof window !== "undefined" ? window.location.href : "http://localhost");
2797
+ if (u.host && typeof window !== "undefined" && u.host !== window.location.host) return null;
2798
+ return u.pathname;
2799
+ } catch {
2800
+ return null;
2801
+ }
2802
+ }).filter((p) => p != null && p.length > 0 && p.length < 200);
2803
+ },
2804
+ getUserId: () => userInfo?.id ?? null,
2805
+ getSessionId: core.getSessionId,
2806
+ onEvent: (event) => {
2807
+ void apiClient.postDiscoveryEvent({
2808
+ ...event,
2809
+ sdk_version: MUSHI_SDK_VERSION
2810
+ }).catch((err) => {
2811
+ log.debug("discovery emit failed", { err: String(err) });
2812
+ });
2813
+ }
2814
+ });
2815
+ } else {
2816
+ discoveryCap?.destroy();
2817
+ discoveryCap = null;
2818
+ }
2579
2819
  }
2580
2820
  const listeners = /* @__PURE__ */ new Map();
2581
2821
  function emit(type, data) {
@@ -2922,6 +3162,8 @@ function createInstance(config) {
2922
3162
  perfCap?.destroy();
2923
3163
  elementSelector?.deactivate();
2924
3164
  timelineCap.destroy();
3165
+ discoveryCap?.destroy();
3166
+ discoveryCap = null;
2925
3167
  offlineQueue.stopAutoSync();
2926
3168
  listeners.clear();
2927
3169
  instance = null;