@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 +42 -0
- package/dist/index.cjs +244 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +244 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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.
|
|
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;
|