anlyx 0.1.2 → 0.1.3

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.
@@ -6,7 +6,9 @@ import { fileURLToPath } from "node:url";
6
6
  import { scanResultSchema } from "@anlyx/core";
7
7
  import { createServer } from "vite";
8
8
  import { loadConfig } from "./config-loader.js";
9
+ import { runScanCommand } from "./scan-command.js";
9
10
  const require = createRequire(import.meta.url);
11
+ const activeLocalUiServers = new Set();
10
12
  export async function runDevCommand(options = {}) {
11
13
  const cwd = resolve(options.cwd ?? process.cwd());
12
14
  const dependencies = withDefaultDependencies(options.dependencies);
@@ -14,24 +16,97 @@ export async function runDevCommand(options = {}) {
14
16
  cwd,
15
17
  ...(options.configPath ? { configPath: options.configPath } : {})
16
18
  });
17
- const reportDataPath = join(resolve(cwd, options.outputDir ?? ".anlyx"), "report-data.json");
18
- const reportData = await dependencies.readReportData(reportDataPath);
19
+ const outputDir = resolve(cwd, options.outputDir ?? ".anlyx");
20
+ const reportDataPath = join(outputDir, "report-data.json");
21
+ const { reportData, scanRan } = await ensureReportData({
22
+ cwd,
23
+ reportDataPath,
24
+ dependencies,
25
+ ...(options.configPath ? { configPath: options.configPath } : {}),
26
+ ...(options.outputDir ? { outputDir: options.outputDir } : {})
27
+ });
28
+ const frontendStarted = await ensureFrontendDevServer({
29
+ cwd,
30
+ config,
31
+ dependencies
32
+ });
19
33
  const port = options.port ?? getConfiguredPort(config);
20
34
  const server = await dependencies.createLocalUiServer({
21
35
  port,
22
36
  reportData,
23
- viewerRoot: getViewerRoot()
37
+ viewerRoot: getViewerRoot(),
38
+ frontendBaseUrl: config.frontend.baseUrl,
39
+ mode: config.server.mode
24
40
  });
41
+ activeLocalUiServers.add(server);
25
42
  const shouldOpenBrowser = options.open ?? config.server.openBrowser;
43
+ const browserUrl = config.server.mode === "inject" ? config.frontend.baseUrl : server.url;
26
44
  if (shouldOpenBrowser) {
27
- await dependencies.openBrowser(server.url);
45
+ await dependencies.openBrowser(browserUrl);
28
46
  }
29
47
  return {
30
48
  url: server.url,
31
49
  port,
32
- reportDataPath
50
+ reportDataPath,
51
+ mode: config.server.mode,
52
+ frontendStarted,
53
+ scanRan,
54
+ ...(config.server.mode === "inject" ? { frontendUrl: config.frontend.baseUrl } : {}),
55
+ ...(config.server.mode === "inject" ? { scriptTag: getOverlayScriptTag(server.url) } : {})
33
56
  };
34
57
  }
58
+ export async function closeActiveLocalUiServers() {
59
+ const servers = Array.from(activeLocalUiServers);
60
+ activeLocalUiServers.clear();
61
+ await Promise.all(servers.map(async (server) => {
62
+ await server.close?.();
63
+ }));
64
+ }
65
+ async function ensureReportData(options) {
66
+ try {
67
+ return {
68
+ reportData: await options.dependencies.readReportData(options.reportDataPath),
69
+ scanRan: false
70
+ };
71
+ }
72
+ catch (error) {
73
+ if (!isMissingReportDataError(error)) {
74
+ throw error;
75
+ }
76
+ }
77
+ try {
78
+ await options.dependencies.runScanCommand({
79
+ cwd: options.cwd,
80
+ ...(options.configPath ? { configPath: options.configPath } : {}),
81
+ ...(options.outputDir ? { outputDir: options.outputDir } : {}),
82
+ skipCapture: true
83
+ });
84
+ }
85
+ catch (error) {
86
+ throw new Error(`Automatic scan failed: ${error instanceof Error ? error.message : "unknown error"}`);
87
+ }
88
+ return {
89
+ reportData: await options.dependencies.readReportData(options.reportDataPath),
90
+ scanRan: true
91
+ };
92
+ }
93
+ async function ensureFrontendDevServer(options) {
94
+ const command = options.config.dev?.command;
95
+ if (!command) {
96
+ return false;
97
+ }
98
+ if (await options.dependencies.isFrontendReachable(options.config.frontend.baseUrl)) {
99
+ return false;
100
+ }
101
+ options.dependencies.startFrontendDevServer({
102
+ command,
103
+ cwd: options.cwd
104
+ });
105
+ return true;
106
+ }
107
+ function isMissingReportDataError(error) {
108
+ return error instanceof Error && /Run "anlyx scan" first/.test(error.message);
109
+ }
35
110
  export async function readReportData(path) {
36
111
  let content;
37
112
  try {
@@ -71,7 +146,7 @@ export async function createLocalUiServer(options) {
71
146
  port: options.port,
72
147
  strictPort: true
73
148
  },
74
- plugins: [createReportDataPlugin(options.reportData)]
149
+ plugins: [createAnlyxDevPlugin(options)]
75
150
  });
76
151
  await viteServer.listen();
77
152
  return {
@@ -87,33 +162,1309 @@ export async function openBrowser(url) {
87
162
  });
88
163
  child.unref();
89
164
  }
90
- function createReportDataPlugin(reportData) {
165
+ export async function isFrontendReachable(url) {
166
+ const controller = new AbortController();
167
+ const timeout = setTimeout(() => controller.abort(), 1200);
168
+ try {
169
+ const response = await fetch(url, {
170
+ method: "GET",
171
+ signal: controller.signal
172
+ });
173
+ return response.status < 500;
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ finally {
179
+ clearTimeout(timeout);
180
+ }
181
+ }
182
+ export function startFrontendDevServer(options) {
183
+ const child = spawn(options.command, {
184
+ cwd: options.cwd,
185
+ shell: true,
186
+ stdio: "inherit"
187
+ });
188
+ return {
189
+ stop: () => {
190
+ if (!child.killed) {
191
+ child.kill();
192
+ }
193
+ }
194
+ };
195
+ }
196
+ function createAnlyxDevPlugin(options) {
91
197
  return {
92
- name: "anlyx-report-data",
198
+ name: "anlyx-dev-runtime",
93
199
  configureServer(server) {
94
200
  server.middlewares.use((request, response, next) => {
95
- if (request.method === "GET" && request.url === "/") {
201
+ if (request.method === "GET" && options.mode === "viewer" && request.url === "/") {
96
202
  request.url = "/viewer.html";
97
203
  }
98
204
  next();
99
205
  });
100
- server.middlewares.use("/api/report-data", (request, response, next) => {
101
- if (request.method !== "GET") {
206
+ server.middlewares.use(async (request, response, next) => {
207
+ const requestUrl = request.url ?? "/";
208
+ if (request.method === "OPTIONS" && isAnlyxPath(requestUrl)) {
209
+ response.statusCode = 204;
210
+ setCorsHeaders(response);
211
+ response.end();
212
+ return;
213
+ }
214
+ if (request.method === "GET" && isReportDataPath(requestUrl)) {
215
+ sendJson(response, options.reportData);
216
+ return;
217
+ }
218
+ if (request.method === "GET" && requestUrl === "/_anlyx/overlay.js") {
219
+ response.statusCode = 200;
220
+ setCorsHeaders(response);
221
+ response.setHeader("content-type", "application/javascript; charset=utf-8");
222
+ response.end(getOverlayClientScript());
223
+ return;
224
+ }
225
+ if (request.method === "GET" && requestUrl === "/_anlyx/overlay-ui.js") {
226
+ await sendRuntimeAsset(response, join(options.viewerRoot, "../overlay/overlay-ui.js"), "application/javascript; charset=utf-8");
227
+ return;
228
+ }
229
+ if (request.method === "GET" && requestUrl === "/_anlyx/overlay-ui.css") {
230
+ await sendRuntimeAsset(response, join(options.viewerRoot, "../overlay/overlay-ui.css"), "text/css; charset=utf-8");
231
+ return;
232
+ }
233
+ if (request.method === "GET" && options.mode === "inject" && requestUrl === "/") {
234
+ response.statusCode = 200;
235
+ response.setHeader("content-type", "text/html; charset=utf-8");
236
+ response.end(getInjectModeHtml(options.frontendBaseUrl, getOverlayScriptTag(getServerUrl(options.port))));
237
+ return;
238
+ }
239
+ if (request.method === "GET" && isStandaloneViewerPath(requestUrl)) {
240
+ request.url = "/viewer.html";
102
241
  next();
103
242
  return;
104
243
  }
105
- response.statusCode = 200;
106
- response.setHeader("content-type", "application/json; charset=utf-8");
107
- response.end(JSON.stringify(reportData));
244
+ if (options.mode === "viewer" || options.mode === "inject") {
245
+ next();
246
+ return;
247
+ }
248
+ if (isAnlyxPath(requestUrl)) {
249
+ response.statusCode = 404;
250
+ response.setHeader("content-type", "text/plain; charset=utf-8");
251
+ response.end("Anlyx runtime asset not found.");
252
+ return;
253
+ }
254
+ await proxyToFrontend(request, response, options.frontendBaseUrl);
108
255
  });
109
256
  }
110
257
  };
111
258
  }
259
+ function isReportDataPath(path) {
260
+ return path === "/_anlyx/report-data" || path === "/api/report-data";
261
+ }
262
+ function isStandaloneViewerPath(path) {
263
+ return path === "/_anlyx/viewer" || path === "/_anlyx/viewer.html";
264
+ }
265
+ function isAnlyxPath(path) {
266
+ return path.startsWith("/_anlyx/");
267
+ }
268
+ function sendJson(response, value) {
269
+ response.statusCode = 200;
270
+ setCorsHeaders(response);
271
+ response.setHeader("content-type", "application/json; charset=utf-8");
272
+ response.end(JSON.stringify(value));
273
+ }
274
+ async function sendRuntimeAsset(response, path, contentType) {
275
+ try {
276
+ const content = await readFile(path);
277
+ response.statusCode = 200;
278
+ setCorsHeaders(response);
279
+ response.setHeader("content-type", contentType);
280
+ response.end(content);
281
+ }
282
+ catch {
283
+ response.statusCode = 404;
284
+ setCorsHeaders(response);
285
+ response.setHeader("content-type", "text/plain; charset=utf-8");
286
+ response.end("Anlyx runtime asset not found.");
287
+ }
288
+ }
289
+ function setCorsHeaders(response) {
290
+ response.setHeader("access-control-allow-origin", "*");
291
+ response.setHeader("access-control-allow-methods", "GET, OPTIONS");
292
+ response.setHeader("access-control-allow-headers", "content-type");
293
+ }
294
+ function getServerUrl(port) {
295
+ return `http://localhost:${port}`;
296
+ }
297
+ async function proxyToFrontend(request, response, frontendBaseUrl) {
298
+ const targetUrl = buildProxyTargetUrl(frontendBaseUrl, request.url ?? "/");
299
+ const method = request.method ?? "GET";
300
+ try {
301
+ const requestInit = {
302
+ method,
303
+ headers: getProxyRequestHeaders(request),
304
+ redirect: "manual"
305
+ };
306
+ if (method !== "GET" && method !== "HEAD") {
307
+ const body = await readRequestBody(request);
308
+ if (body !== undefined) {
309
+ requestInit.body = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
310
+ }
311
+ }
312
+ const upstream = await fetch(targetUrl, requestInit);
313
+ response.statusCode = upstream.status;
314
+ response.statusMessage = upstream.statusText;
315
+ upstream.headers.forEach((value, key) => {
316
+ if (!shouldOmitProxyResponseHeader(key)) {
317
+ response.setHeader(key, value);
318
+ }
319
+ });
320
+ const contentType = upstream.headers.get("content-type") ?? "";
321
+ if (method === "GET" && contentType.includes("text/html")) {
322
+ const html = await upstream.text();
323
+ response.setHeader("content-type", contentType);
324
+ response.end(injectOverlayScript(html));
325
+ return;
326
+ }
327
+ const body = Buffer.from(await upstream.arrayBuffer());
328
+ response.end(body);
329
+ }
330
+ catch (error) {
331
+ response.statusCode = 502;
332
+ response.setHeader("content-type", "text/html; charset=utf-8");
333
+ response.end(getProxyErrorHtml(frontendBaseUrl, error));
334
+ }
335
+ }
336
+ export function buildProxyTargetUrl(frontendBaseUrl, requestUrl) {
337
+ const base = new URL(frontendBaseUrl);
338
+ const target = new URL(requestUrl, base);
339
+ target.protocol = base.protocol;
340
+ target.host = base.host;
341
+ return target.toString();
342
+ }
343
+ export function injectOverlayScript(html) {
344
+ if (html.includes("/_anlyx/overlay.js")) {
345
+ return html;
346
+ }
347
+ const script = '<script src="/_anlyx/overlay.js" defer></script>';
348
+ if (html.includes("</body>")) {
349
+ return html.replace("</body>", `${script}</body>`);
350
+ }
351
+ return `${html}${script}`;
352
+ }
353
+ export function getOverlayScriptTag(serverUrl) {
354
+ return `<script src="${serverUrl}/_anlyx/overlay.js" defer></script>`;
355
+ }
356
+ function getInjectModeHtml(frontendBaseUrl, scriptTag) {
357
+ return `<!doctype html>
358
+ <html lang="en">
359
+ <head>
360
+ <meta charset="utf-8" />
361
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
362
+ <title>Anlyx Inject Mode</title>
363
+ <style>
364
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8fafc; color: #0f172a; }
365
+ main { width: min(760px, calc(100vw - 32px)); border: 1px solid #dbe4f0; border-radius: 16px; background: white; box-shadow: 0 18px 60px rgba(15, 23, 42, 0.12); padding: 28px; }
366
+ h1 { margin: 0 0 10px; font-size: 22px; line-height: 1.25; }
367
+ p { margin: 8px 0; color: #475569; line-height: 1.55; }
368
+ pre { overflow: auto; border-radius: 12px; background: #0f172a; color: #e2e8f0; padding: 14px; font-size: 13px; line-height: 1.5; }
369
+ a { color: #2563eb; font-weight: 800; text-decoration: none; }
370
+ </style>
371
+ </head>
372
+ <body>
373
+ <main>
374
+ <h1>Anlyx runtime is ready</h1>
375
+ <p>Open your real frontend at <a href="${escapeHtml(frontendBaseUrl)}">${escapeHtml(frontendBaseUrl)}</a> and inject this local-only script into the app during development.</p>
376
+ <pre>${escapeHtml(scriptTag)}</pre>
377
+ <p>The standalone debug viewer is still available at <a href="/_anlyx/viewer">/_anlyx/viewer</a>.</p>
378
+ </main>
379
+ </body>
380
+ </html>`;
381
+ }
382
+ function getProxyRequestHeaders(request) {
383
+ const headers = new Headers();
384
+ for (const [key, value] of Object.entries(request.headers)) {
385
+ if (value === undefined || shouldOmitProxyRequestHeader(key)) {
386
+ continue;
387
+ }
388
+ if (Array.isArray(value)) {
389
+ for (const item of value) {
390
+ headers.append(key, item);
391
+ }
392
+ continue;
393
+ }
394
+ headers.set(key, value);
395
+ }
396
+ return headers;
397
+ }
398
+ function shouldOmitProxyRequestHeader(key) {
399
+ return ["host", "connection", "content-length"].includes(key.toLowerCase());
400
+ }
401
+ function shouldOmitProxyResponseHeader(key) {
402
+ return ["connection", "content-encoding", "content-length", "transfer-encoding"].includes(key.toLowerCase());
403
+ }
404
+ function readRequestBody(request) {
405
+ return new Promise((resolveBody, reject) => {
406
+ const chunks = [];
407
+ request.on("data", (chunk) => {
408
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
409
+ });
410
+ request.on("end", () => {
411
+ resolveBody(chunks.length > 0 ? Buffer.concat(chunks) : undefined);
412
+ });
413
+ request.on("error", reject);
414
+ });
415
+ }
416
+ function getProxyErrorHtml(frontendBaseUrl, error) {
417
+ const message = error instanceof Error ? error.message : "Unknown proxy error";
418
+ return `<!doctype html>
419
+ <html lang="en">
420
+ <head>
421
+ <meta charset="utf-8" />
422
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
423
+ <title>Anlyx proxy error</title>
424
+ <style>
425
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8fafc; color: #0f172a; }
426
+ main { width: min(560px, calc(100vw - 32px)); border: 1px solid #dbe4f0; border-radius: 16px; background: white; box-shadow: 0 18px 60px rgba(15, 23, 42, 0.12); padding: 28px; }
427
+ h1 { margin: 0 0 10px; font-size: 22px; line-height: 1.25; }
428
+ p { margin: 8px 0; color: #475569; line-height: 1.55; }
429
+ code { border-radius: 8px; background: #eef4ff; color: #1d4ed8; padding: 2px 6px; }
430
+ </style>
431
+ </head>
432
+ <body>
433
+ <main>
434
+ <h1>Anlyx could not reach the frontend app</h1>
435
+ <p>Overlay Mode proxies your configured frontend at <code>${escapeHtml(frontendBaseUrl)}</code>.</p>
436
+ <p>Start the frontend dev server, then refresh this page. The standalone viewer is still available at <code>/_anlyx/viewer</code>.</p>
437
+ <p>${escapeHtml(message)}</p>
438
+ </main>
439
+ </body>
440
+ </html>`;
441
+ }
442
+ function escapeHtml(value) {
443
+ return value
444
+ .replace(/&/g, "&amp;")
445
+ .replace(/</g, "&lt;")
446
+ .replace(/>/g, "&gt;")
447
+ .replace(/"/g, "&quot;")
448
+ .replace(/'/g, "&#39;");
449
+ }
450
+ export function getOverlayClientScript() {
451
+ return String.raw `
452
+ (() => {
453
+ if (window.__ANLYX_OVERLAY_INSTALLED__) {
454
+ return;
455
+ }
456
+ window.__ANLYX_OVERLAY_INSTALLED__ = true;
457
+
458
+ const state = {
459
+ report: null,
460
+ events: [],
461
+ actions: [],
462
+ selectedEventId: null,
463
+ open: false,
464
+ loadError: null
465
+ };
466
+
467
+ let drawer = null;
468
+ let body = null;
469
+ let launcher = null;
470
+ let overlayUiReady = false;
471
+ let overlayUiLoading = false;
472
+ let overlayRootGuardInstalled = false;
473
+ let overlayRootRestoreScheduled = false;
474
+ let overlayInfrastructureInstalled = false;
475
+ const endpointRegexCache = new Map();
476
+ const currentScript = document.currentScript;
477
+ const runtimeBaseUrl = currentScript && currentScript.src ? new URL(currentScript.src).origin : window.location.origin;
478
+ const ANLYX_PENDING_ACTION_KEY = "__anlyx_pending_action__";
479
+ const ANLYX_DRAWER_SETTINGS_KEY = "__anlyx_drawer_settings__";
480
+ const ANLYX_LAUNCHER_SETTINGS_KEY = "__anlyx_launcher_settings__";
481
+ const drawerSettings = Object.assign({
482
+ width: 600,
483
+ height: 760,
484
+ x: null,
485
+ y: 12,
486
+ opacity: 0.98,
487
+ language: "en"
488
+ }, restoreDrawerSettings());
489
+ const launcherSettings = Object.assign({
490
+ x: null,
491
+ y: null,
492
+ expandedUntil: 0
493
+ }, restoreLauncherSettings());
494
+
495
+ scheduleOverlayMount();
496
+
497
+ function scheduleOverlayMount() {
498
+ const mount = () => window.setTimeout(mountOverlayUi, 0);
499
+
500
+ if (document.body) {
501
+ mount();
502
+ return;
503
+ }
504
+
505
+ if (document.readyState === "loading") {
506
+ document.addEventListener("DOMContentLoaded", mount, { once: true });
507
+ return;
508
+ }
509
+
510
+ mount();
511
+ }
512
+
513
+ function mountOverlayUi() {
514
+ const existingRoot = document.getElementById("anlyx-overlay-root");
515
+ if (existingRoot) {
516
+ launcher = existingRoot.querySelector(".anlyx-fab");
517
+ drawer = existingRoot.querySelector(".anlyx-drawer");
518
+ body = existingRoot.querySelector(".anlyx-body");
519
+ if (drawer && body) {
520
+ installOverlayRootGuard();
521
+ applyLauncherSettings();
522
+ render();
523
+ return;
524
+ }
525
+ existingRoot.remove();
526
+ }
527
+
528
+ if (!document.body) {
529
+ return;
530
+ }
531
+
532
+ if (!document.querySelector("style[data-anlyx-overlay-base]")) {
533
+ const style = document.createElement("style");
534
+ style.setAttribute("data-anlyx-overlay-base", "true");
535
+ style.textContent = ${"`"}
536
+ #anlyx-overlay-root { position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #0f172a; }
537
+ .anlyx-fab { pointer-events: auto; position: absolute; left: auto; top: auto; right: 18px; bottom: 18px; display: inline-flex; align-items: center; justify-content: flex-start; gap: 3px; width: 38px; height: 38px; min-width: 38px; max-width: 38px; padding: 0; overflow: hidden; border: 1px solid rgba(37, 99, 235, .20); border-radius: 999px; background: rgba(37, 99, 235, .72); color: white; font-weight: 850; font-size: 12px; box-shadow: 0 14px 36px rgba(37, 99, 235, 0.20); cursor: grab; opacity: .72; backdrop-filter: blur(10px); transition: width 160ms ease, max-width 160ms ease, opacity 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
538
+ .anlyx-fab:hover, .anlyx-fab:focus-visible, .anlyx-fab[data-expanded="true"] { width: 86px; max-width: 86px; opacity: .96; background: rgba(37, 99, 235, .96); box-shadow: 0 18px 46px rgba(37, 99, 235, 0.26); transform: translate(-48px, -1px); }
539
+ .anlyx-fab:active { cursor: grabbing; transform: translateY(0); }
540
+ .anlyx-fab__mark { width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 38px; }
541
+ .anlyx-fab__mark svg { width: 22px; height: 22px; display: block; filter: drop-shadow(0 1px 1px rgba(15, 23, 42, .16)); }
542
+ .anlyx-fab__label { padding-right: 10px; white-space: nowrap; line-height: 1; letter-spacing: 0; opacity: 0; transform: translateX(-4px); transition: opacity 140ms ease, transform 140ms ease; }
543
+ .anlyx-fab:hover .anlyx-fab__label, .anlyx-fab:focus-visible .anlyx-fab__label, .anlyx-fab[data-expanded="true"] .anlyx-fab__label { opacity: 1; transform: translateX(0); }
544
+ .anlyx-drawer { pointer-events: auto; position: absolute; top: 12px; left: auto; right: auto; width: 600px; height: min(760px, calc(100vh - 24px)); min-width: 420px; min-height: 420px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); border: 1px solid rgba(15, 23, 42, .12); border-radius: 18px; background: rgba(248, 250, 252, .98); box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22); overflow: hidden; display: none; }
545
+ .anlyx-drawer[data-open="true"] { display: grid; grid-template-rows: auto minmax(0, 1fr); }
546
+ .anlyx-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 12px 14px; border-bottom: 1px solid rgba(15, 23, 42, .08); background: rgba(255,255,255,.88); }
547
+ .anlyx-drag-handle { min-width: 0; cursor: grab; user-select: none; }
548
+ .anlyx-drag-handle:active { cursor: grabbing; }
549
+ .anlyx-title { margin: 0; font-size: 15px; line-height: 1.2; font-weight: 900; letter-spacing: 0; }
550
+ .anlyx-subtitle { margin: 3px 0 0; font-size: 11px; color: #64748b; font-weight: 650; }
551
+ .anlyx-shell-controls { display: flex; align-items: center; gap: 7px; }
552
+ .anlyx-shell-field { display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 8px; border: 1px solid #e2e8f0; border-radius: 10px; background: rgba(255,255,255,.9); color: #475569; font-size: 10px; font-weight: 800; white-space: nowrap; }
553
+ .anlyx-opacity-control { width: 70px; accent-color: #2563eb; }
554
+ .anlyx-language-control { width: 58px; border: 0; outline: 0; background: transparent; color: #0f172a; font-size: 10px; font-weight: 900; }
555
+ .anlyx-close { border: 1px solid #e2e8f0; background: #fff; border-radius: 10px; width: 32px; height: 32px; cursor: pointer; font-size: 17px; line-height: 1; color: #0f172a; box-shadow: 0 1px 2px rgba(15, 23, 42, .06); }
556
+ .anlyx-body { overflow: auto; padding: 12px; background: #f8fafc; }
557
+ .anlyx-resize-handle { position: absolute; left: 0; bottom: 0; width: 22px; height: 22px; cursor: nesw-resize; opacity: .62; }
558
+ .anlyx-resize-handle::before { content: ""; position: absolute; left: 6px; bottom: 6px; width: 10px; height: 10px; border-left: 2px solid #94a3b8; border-bottom: 2px solid #94a3b8; border-radius: 0 0 0 3px; }
559
+ .anlyx-section { border: 1px solid rgba(15, 23, 42, .08); border-radius: 14px; background: #fff; margin-bottom: 10px; overflow: hidden; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); }
560
+ .anlyx-section-title { margin: 0; padding: 10px 12px; font-size: 10px; text-transform: uppercase; color: #64748b; letter-spacing: .08em; border-bottom: 1px solid #eef2f7; font-weight: 900; }
561
+ .anlyx-empty { padding: 18px 12px; color: #667085; font-size: 13px; line-height: 1.5; }
562
+ @media (max-width: 700px) {
563
+ .anlyx-drawer { min-width: 0; width: calc(100vw - 16px); height: calc(100vh - 16px); border-radius: 14px; }
564
+ .anlyx-head { grid-template-columns: 1fr; }
565
+ .anlyx-shell-controls { justify-content: space-between; }
566
+ }
567
+ ${"`"};
568
+ document.head.appendChild(style);
569
+ }
570
+
571
+ const root = document.createElement("div");
572
+ root.id = "anlyx-overlay-root";
573
+ root.innerHTML = ${"`"}
574
+ <button class="anlyx-fab" type="button" aria-label="Open Anlyx" title="Open Anlyx">
575
+ <span class="anlyx-fab__mark" aria-hidden="true">
576
+ <svg viewBox="0 0 32 32" role="img" focusable="false">
577
+ <circle cx="8" cy="7" r="4.3" fill="none" stroke="currentColor" stroke-width="2.7" />
578
+ <path d="M12.4 7h5.2c2.2 0 4 1.8 4 4v3.7" fill="none" stroke="currentColor" stroke-width="2.7" stroke-linecap="round" />
579
+ <path d="M20.2 17.3h-5.6c-2.2 0-4 1.8-4 4v3.1h9.1" fill="none" stroke="currentColor" stroke-width="2.7" stroke-linecap="round" stroke-linejoin="round" />
580
+ <rect x="20.2" y="21.2" width="6.4" height="6.4" rx="1.8" fill="none" stroke="currentColor" stroke-width="2.7" />
581
+ <rect x="15" y="13.1" width="5.2" height="5.2" rx="1" fill="#f59e0b" transform="rotate(45 17.6 15.7)" />
582
+ </svg>
583
+ </span>
584
+ <span class="anlyx-fab__label">Anlyx</span>
585
+ </button>
586
+ <aside class="anlyx-drawer" aria-label="Anlyx flow drawer">
587
+ <div class="anlyx-head">
588
+ <div class="anlyx-drag-handle" data-anlyx-label="Move Anlyx drawer">
589
+ <h2 class="anlyx-title">Anlyx Flow Drawer</h2>
590
+ <p class="anlyx-subtitle">Click the real app and inspect the API flow.</p>
591
+ </div>
592
+ <div class="anlyx-shell-controls">
593
+ <label class="anlyx-shell-field">
594
+ <span class="anlyx-opacity-label">Opacity</span>
595
+ <input class="anlyx-opacity-control" type="range" min="70" max="100" step="5" aria-label="Anlyx opacity" />
596
+ </label>
597
+ <label class="anlyx-shell-field">
598
+ <span class="anlyx-language-label">Lang</span>
599
+ <select class="anlyx-language-control" aria-label="Anlyx language">
600
+ <option value="en">EN</option>
601
+ <option value="ko">KO</option>
602
+ </select>
603
+ </label>
604
+ <button class="anlyx-close" type="button" aria-label="Close Anlyx">×</button>
605
+ </div>
606
+ </div>
607
+ <div class="anlyx-body"></div>
608
+ <div class="anlyx-resize-handle" role="separator" aria-label="Resize Anlyx drawer"></div>
609
+ </aside>
610
+ ${"`"};
611
+ document.body.appendChild(root);
612
+
613
+ const button = root.querySelector(".anlyx-fab");
614
+ launcher = button;
615
+ drawer = root.querySelector(".anlyx-drawer");
616
+ body = root.querySelector(".anlyx-body");
617
+ const closeButton = root.querySelector(".anlyx-close");
618
+ const opacityControl = root.querySelector(".anlyx-opacity-control");
619
+ const languageControl = root.querySelector(".anlyx-language-control");
620
+ const dragHandle = root.querySelector(".anlyx-drag-handle");
621
+ const resizeHandle = root.querySelector(".anlyx-resize-handle");
622
+
623
+ installLauncherDrag(button);
624
+ button.addEventListener("click", () => {
625
+ if (button.__anlyxSuppressClick) {
626
+ button.__anlyxSuppressClick = false;
627
+ return;
628
+ }
629
+ state.open = !state.open;
630
+ render();
631
+ });
632
+ closeButton.addEventListener("click", () => {
633
+ state.open = false;
634
+ render();
635
+ });
636
+ if (opacityControl) {
637
+ opacityControl.addEventListener("input", () => {
638
+ drawerSettings.opacity = Number(opacityControl.value || 98) / 100;
639
+ applyDrawerSettings();
640
+ persistDrawerSettings();
641
+ });
642
+ }
643
+ if (languageControl) {
644
+ languageControl.addEventListener("change", () => {
645
+ drawerSettings.language = languageControl.value === "ko" ? "ko" : "en";
646
+ applyDrawerSettings();
647
+ persistDrawerSettings();
648
+ });
649
+ }
650
+ installDrawerDrag(dragHandle);
651
+ installDrawerResize(resizeHandle);
652
+ applyDrawerSettings();
653
+ applyLauncherSettings();
654
+
655
+ installOverlayRootGuard();
656
+
657
+ if (!overlayInfrastructureInstalled) {
658
+ overlayInfrastructureInstalled = true;
659
+ restorePendingAction();
660
+ installUserActionTracker(root);
661
+ installFetchInterceptor();
662
+ installXhrInterceptor();
663
+ loadReport();
664
+ }
665
+
666
+ render();
667
+ }
668
+
669
+ function applyDrawerSettings() {
670
+ if (!drawer) {
671
+ return;
672
+ }
673
+ const viewportWidth = window.innerWidth || 1280;
674
+ const viewportHeight = window.innerHeight || 800;
675
+ const width = clamp(Number(drawerSettings.width) || 600, Math.min(420, viewportWidth - 16), viewportWidth - 16);
676
+ const height = clamp(Number(drawerSettings.height) || 760, Math.min(420, viewportHeight - 16), viewportHeight - 16);
677
+ const defaultX = viewportWidth - width - 12;
678
+ const x = clamp(drawerSettings.x === null ? defaultX : Number(drawerSettings.x), 8, viewportWidth - width - 8);
679
+ const y = clamp(Number(drawerSettings.y) || 12, 8, viewportHeight - height - 8);
680
+ drawerSettings.width = Math.round(width);
681
+ drawerSettings.height = Math.round(height);
682
+ drawerSettings.x = Math.round(x);
683
+ drawerSettings.y = Math.round(y);
684
+ drawerSettings.opacity = clamp(Number(drawerSettings.opacity) || 0.98, 0.7, 1);
685
+ drawer.style.width = drawerSettings.width + "px";
686
+ drawer.style.height = drawerSettings.height + "px";
687
+ drawer.style.left = drawerSettings.x + "px";
688
+ drawer.style.top = drawerSettings.y + "px";
689
+ drawer.style.opacity = String(drawerSettings.opacity);
690
+
691
+ const opacityControl = document.querySelector("#anlyx-overlay-root .anlyx-opacity-control");
692
+ const languageControl = document.querySelector("#anlyx-overlay-root .anlyx-language-control");
693
+ if (opacityControl) {
694
+ opacityControl.value = String(Math.round(drawerSettings.opacity * 100));
695
+ }
696
+ if (languageControl) {
697
+ languageControl.value = drawerSettings.language === "ko" ? "ko" : "en";
698
+ }
699
+ applyDrawerLanguage();
700
+ }
701
+
702
+ function applyLauncherSettings() {
703
+ if (!launcher) {
704
+ return;
705
+ }
706
+ const viewportWidth = window.innerWidth || 1280;
707
+ const viewportHeight = window.innerHeight || 800;
708
+ const width = 38;
709
+ const height = 38;
710
+ const defaultX = viewportWidth - width - 18;
711
+ const defaultY = viewportHeight - height - 18;
712
+ const x = clamp(launcherSettings.x === null ? defaultX : Number(launcherSettings.x), 8, viewportWidth - width - 8);
713
+ const y = clamp(launcherSettings.y === null ? defaultY : Number(launcherSettings.y), 8, viewportHeight - height - 8);
714
+ launcherSettings.x = Math.round(x);
715
+ launcherSettings.y = Math.round(y);
716
+ launcher.style.left = launcherSettings.x + "px";
717
+ launcher.style.top = launcherSettings.y + "px";
718
+ launcher.style.right = "auto";
719
+ launcher.style.bottom = "auto";
720
+ launcher.dataset.expanded = Date.now() < Number(launcherSettings.expandedUntil || 0) ? "true" : "false";
721
+ }
722
+
723
+ function applyDrawerLanguage() {
724
+ const isKo = drawerSettings.language === "ko";
725
+ const title = document.querySelector("#anlyx-overlay-root .anlyx-title");
726
+ const subtitle = document.querySelector("#anlyx-overlay-root .anlyx-subtitle");
727
+ const opacityLabel = document.querySelector("#anlyx-overlay-root .anlyx-opacity-label");
728
+ const languageLabel = document.querySelector("#anlyx-overlay-root .anlyx-language-label");
729
+ if (title) {
730
+ title.textContent = isKo ? "Anlyx 플로우 드로어" : "Anlyx Flow Drawer";
731
+ }
732
+ if (subtitle) {
733
+ subtitle.textContent = isKo ? "앱을 그대로 사용하며 API 흐름을 확인하세요." : "Click the real app and inspect the API flow.";
734
+ }
735
+ if (opacityLabel) {
736
+ opacityLabel.textContent = isKo ? "투명도" : "Opacity";
737
+ }
738
+ if (languageLabel) {
739
+ languageLabel.textContent = isKo ? "언어" : "Lang";
740
+ }
741
+ }
742
+
743
+ function installDrawerDrag(handle) {
744
+ if (!handle || !drawer) {
745
+ return;
746
+ }
747
+ handle.addEventListener("pointerdown", (event) => {
748
+ if (event.button !== 0) {
749
+ return;
750
+ }
751
+ const startX = event.clientX;
752
+ const startY = event.clientY;
753
+ const initialX = Number(drawerSettings.x) || drawer.getBoundingClientRect().left;
754
+ const initialY = Number(drawerSettings.y) || drawer.getBoundingClientRect().top;
755
+ const onMove = (moveEvent) => {
756
+ drawerSettings.x = initialX + moveEvent.clientX - startX;
757
+ drawerSettings.y = initialY + moveEvent.clientY - startY;
758
+ applyDrawerSettings();
759
+ };
760
+ const onUp = () => {
761
+ window.removeEventListener("pointermove", onMove);
762
+ window.removeEventListener("pointerup", onUp);
763
+ persistDrawerSettings();
764
+ };
765
+ window.addEventListener("pointermove", onMove);
766
+ window.addEventListener("pointerup", onUp, { once: true });
767
+ });
768
+ }
769
+
770
+ function installDrawerResize(handle) {
771
+ if (!handle || !drawer) {
772
+ return;
773
+ }
774
+ handle.addEventListener("pointerdown", (event) => {
775
+ if (event.button !== 0) {
776
+ return;
777
+ }
778
+ event.preventDefault();
779
+ const startX = event.clientX;
780
+ const startY = event.clientY;
781
+ const initialX = Number(drawerSettings.x) || drawer.getBoundingClientRect().left;
782
+ const initialWidth = Number(drawerSettings.width) || drawer.getBoundingClientRect().width;
783
+ const initialHeight = Number(drawerSettings.height) || drawer.getBoundingClientRect().height;
784
+ const onMove = (moveEvent) => {
785
+ const width = initialWidth - (moveEvent.clientX - startX);
786
+ drawerSettings.width = width;
787
+ drawerSettings.height = initialHeight + moveEvent.clientY - startY;
788
+ drawerSettings.x = initialX + initialWidth - width;
789
+ applyDrawerSettings();
790
+ };
791
+ const onUp = () => {
792
+ window.removeEventListener("pointermove", onMove);
793
+ window.removeEventListener("pointerup", onUp);
794
+ persistDrawerSettings();
795
+ };
796
+ window.addEventListener("pointermove", onMove);
797
+ window.addEventListener("pointerup", onUp, { once: true });
798
+ });
799
+ }
800
+
801
+ function installLauncherDrag(button) {
802
+ if (!button) {
803
+ return;
804
+ }
805
+ button.addEventListener("pointerdown", (event) => {
806
+ if (event.button !== 0) {
807
+ return;
808
+ }
809
+ const startX = event.clientX;
810
+ const startY = event.clientY;
811
+ const initialX = launcherSettings.x === null ? button.getBoundingClientRect().left : Number(launcherSettings.x);
812
+ const initialY = launcherSettings.y === null ? button.getBoundingClientRect().top : Number(launcherSettings.y);
813
+ let dragged = false;
814
+ const onMove = (moveEvent) => {
815
+ const deltaX = moveEvent.clientX - startX;
816
+ const deltaY = moveEvent.clientY - startY;
817
+ if (Math.abs(deltaX) + Math.abs(deltaY) > 4) {
818
+ dragged = true;
819
+ }
820
+ if (!dragged) {
821
+ return;
822
+ }
823
+ launcherSettings.x = initialX + deltaX;
824
+ launcherSettings.y = initialY + deltaY;
825
+ applyLauncherSettings();
826
+ };
827
+ const onUp = () => {
828
+ window.removeEventListener("pointermove", onMove);
829
+ window.removeEventListener("pointerup", onUp);
830
+ if (dragged) {
831
+ button.__anlyxSuppressClick = true;
832
+ persistLauncherSettings();
833
+ }
834
+ };
835
+ window.addEventListener("pointermove", onMove);
836
+ window.addEventListener("pointerup", onUp, { once: true });
837
+ });
838
+ }
839
+
840
+ function restoreDrawerSettings() {
841
+ try {
842
+ const raw = window.localStorage && window.localStorage.getItem(ANLYX_DRAWER_SETTINGS_KEY);
843
+ return raw ? JSON.parse(raw) : {};
844
+ } catch {
845
+ return {};
846
+ }
847
+ }
848
+
849
+ function restoreLauncherSettings() {
850
+ try {
851
+ const raw = window.localStorage && window.localStorage.getItem(ANLYX_LAUNCHER_SETTINGS_KEY);
852
+ return raw ? JSON.parse(raw) : {};
853
+ } catch {
854
+ return {};
855
+ }
856
+ }
857
+
858
+ function persistDrawerSettings() {
859
+ try {
860
+ if (window.localStorage) {
861
+ window.localStorage.setItem(ANLYX_DRAWER_SETTINGS_KEY, JSON.stringify(drawerSettings));
862
+ }
863
+ } catch {
864
+ // Ignore storage failures in strict browser privacy modes.
865
+ }
866
+ }
867
+
868
+ function persistLauncherSettings() {
869
+ try {
870
+ if (window.localStorage) {
871
+ window.localStorage.setItem(ANLYX_LAUNCHER_SETTINGS_KEY, JSON.stringify({
872
+ x: launcherSettings.x,
873
+ y: launcherSettings.y
874
+ }));
875
+ }
876
+ } catch {
877
+ // Ignore storage failures in strict browser privacy modes.
878
+ }
879
+ }
880
+
881
+ function clamp(value, min, max) {
882
+ return Math.min(Math.max(value, min), max);
883
+ }
884
+
885
+ function installOverlayRootGuard() {
886
+ if (overlayRootGuardInstalled || !document.body || !window.MutationObserver) {
887
+ return;
888
+ }
889
+ overlayRootGuardInstalled = true;
890
+ const observer = new MutationObserver(() => {
891
+ if (!document.body || document.getElementById("anlyx-overlay-root") || overlayRootRestoreScheduled) {
892
+ return;
893
+ }
894
+ overlayRootRestoreScheduled = true;
895
+ window.setTimeout(() => {
896
+ overlayRootRestoreScheduled = false;
897
+ mountOverlayUi();
898
+ }, 50);
899
+ });
900
+ observer.observe(document.body, { childList: true });
901
+ }
902
+
903
+ function loadOverlayUiAssets() {
904
+ if (!document.querySelector("link[data-anlyx-overlay-ui]")) {
905
+ const link = document.createElement("link");
906
+ link.rel = "stylesheet";
907
+ link.href = runtimeBaseUrl + "/_anlyx/overlay-ui.css";
908
+ link.setAttribute("data-anlyx-overlay-ui", "true");
909
+ document.head.appendChild(link);
910
+ }
911
+
912
+ if (window.__ANLYX_RENDER_FLOW_DRAWER__) {
913
+ overlayUiReady = true;
914
+ return;
915
+ }
916
+
917
+ if (overlayUiLoading) {
918
+ return;
919
+ }
920
+
921
+ overlayUiLoading = true;
922
+ const script = document.createElement("script");
923
+ script.src = runtimeBaseUrl + "/_anlyx/overlay-ui.js";
924
+ script.defer = true;
925
+ script.onload = () => {
926
+ overlayUiReady = true;
927
+ render();
928
+ };
929
+ script.onerror = () => {
930
+ overlayUiReady = false;
931
+ render();
932
+ };
933
+ document.head.appendChild(script);
934
+ }
935
+
936
+ async function loadReport() {
937
+ try {
938
+ const response = await fetch(runtimeBaseUrl + "/_anlyx/report-data");
939
+ if (!response.ok) {
940
+ throw new Error("Report data request failed with status " + response.status);
941
+ }
942
+ state.report = await response.json();
943
+ render();
944
+ } catch (error) {
945
+ state.loadError = error instanceof Error ? error.message : "Failed to load report data";
946
+ render();
947
+ }
948
+ }
949
+
950
+ function installFetchInterceptor() {
951
+ const originalFetch = window.fetch;
952
+ window.fetch = async function anlyxFetch(input, init) {
953
+ const method = ((init && init.method) || (input && input.method) || "GET").toUpperCase();
954
+ const url = typeof input === "string" ? input : input && input.url;
955
+ const startedAt = performance.now();
956
+ try {
957
+ const response = await originalFetch.apply(this, arguments);
958
+ if (shouldTrackRequestUrl(url)) {
959
+ scheduleApiEventRecord({ method, url, status: response.status, durationMs: performance.now() - startedAt, startedAt });
960
+ }
961
+ return response;
962
+ } catch (error) {
963
+ if (shouldTrackRequestUrl(url)) {
964
+ scheduleApiEventRecord({ method, url, status: "failed", durationMs: performance.now() - startedAt, startedAt });
965
+ }
966
+ throw error;
967
+ }
968
+ };
969
+ }
970
+
971
+ function installUserActionTracker(root) {
972
+ document.addEventListener("pointerdown", (event) => captureUserAction(event, root), true);
973
+ document.addEventListener("click", (event) => captureUserAction(event, root), true);
974
+ document.addEventListener("submit", (event) => captureUserAction(event, root), true);
975
+ document.addEventListener("keydown", (event) => {
976
+ if (event.key === "Enter" || event.key === " ") {
977
+ captureUserAction(event, root);
978
+ }
979
+ }, true);
980
+ }
981
+
982
+ function captureUserAction(event, root) {
983
+ const target = getActionTarget(event.target);
984
+ if (!target || root.contains(target)) {
985
+ return;
986
+ }
987
+
988
+ const action = {
989
+ id: String(Date.now()) + "-" + Math.random().toString(36).slice(2),
990
+ type: getActionType(event, target),
991
+ label: getActionLabel(target),
992
+ selector: getElementPath(target),
993
+ at: performance.now(),
994
+ capturedAt: Date.now()
995
+ };
996
+ rememberAction(action);
997
+ persistPendingAction(action);
998
+ }
999
+
1000
+ function rememberAction(action) {
1001
+ state.actions = [action].concat(state.actions).slice(0, 20);
1002
+ }
1003
+
1004
+ function persistPendingAction(action) {
1005
+ try {
1006
+ window.sessionStorage.setItem(ANLYX_PENDING_ACTION_KEY, JSON.stringify(action));
1007
+ } catch {
1008
+ // Ignore storage failures in strict browser privacy modes.
1009
+ }
1010
+ }
1011
+
1012
+ function restorePendingAction() {
1013
+ try {
1014
+ const raw = window.sessionStorage.getItem(ANLYX_PENDING_ACTION_KEY);
1015
+ if (!raw) {
1016
+ return;
1017
+ }
1018
+ const action = JSON.parse(raw);
1019
+ if (isFreshAction(action)) {
1020
+ rememberAction(Object.assign({}, action, { at: performance.now() }));
1021
+ return;
1022
+ }
1023
+ window.sessionStorage.removeItem(ANLYX_PENDING_ACTION_KEY);
1024
+ } catch {
1025
+ try {
1026
+ window.sessionStorage.removeItem(ANLYX_PENDING_ACTION_KEY);
1027
+ } catch {
1028
+ // Ignore storage cleanup failures.
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ function isFreshAction(action) {
1034
+ return action && action.capturedAt && Date.now() - action.capturedAt <= 8000;
1035
+ }
1036
+
1037
+ function getActionTarget(target) {
1038
+ if (!target || !target.closest) {
1039
+ return null;
1040
+ }
1041
+ return target.closest("[data-anlyx-label], button, a, input, select, textarea, label, summary, [role='button'], [role='link'], [role='menuitem'], [role='tab']");
1042
+ }
1043
+
1044
+ function getActionType(event, target) {
1045
+ if (event.type === "submit") {
1046
+ return "Submitted";
1047
+ }
1048
+ if (event.type === "keydown") {
1049
+ return "Pressed";
1050
+ }
1051
+ if (target.tagName === "A" || target.getAttribute("role") === "link") {
1052
+ return "Opened";
1053
+ }
1054
+ return "Clicked";
1055
+ }
1056
+
1057
+ function getActionLabel(target) {
1058
+ const label =
1059
+ target.getAttribute("data-anlyx-label") ||
1060
+ target.getAttribute("aria-label") ||
1061
+ target.getAttribute("title") ||
1062
+ target.getAttribute("placeholder") ||
1063
+ target.name ||
1064
+ target.textContent ||
1065
+ target.value ||
1066
+ getElementPath(target);
1067
+ return compactLabel(label);
1068
+ }
1069
+
1070
+ function compactLabel(value) {
1071
+ const label = String(value || "").replace(/\s+/g, " ").trim();
1072
+ if (!label) {
1073
+ return "unnamed element";
1074
+ }
1075
+ return label.length > 80 ? label.slice(0, 77) + "..." : label;
1076
+ }
1077
+
1078
+ function getElementPath(target) {
1079
+ const tag = String(target.tagName || "element").toLowerCase();
1080
+ if (target.id) {
1081
+ return tag + "#" + target.id;
1082
+ }
1083
+ const testId = target.getAttribute("data-testid");
1084
+ if (testId) {
1085
+ return tag + "[data-testid='" + testId + "']";
1086
+ }
1087
+ const className = String(target.className || "").split(/\s+/).filter(Boolean).slice(0, 2).join(".");
1088
+ return className ? tag + "." + className : tag;
1089
+ }
1090
+
1091
+ function installXhrInterceptor() {
1092
+ const originalOpen = XMLHttpRequest.prototype.open;
1093
+ const originalSend = XMLHttpRequest.prototype.send;
1094
+
1095
+ XMLHttpRequest.prototype.open = function anlyxOpen(method, url) {
1096
+ this.__anlyxRequest = { method: String(method || "GET").toUpperCase(), url: String(url || ""), startedAt: 0 };
1097
+ return originalOpen.apply(this, arguments);
1098
+ };
1099
+
1100
+ XMLHttpRequest.prototype.send = function anlyxSend() {
1101
+ const request = this.__anlyxRequest;
1102
+ if (request) {
1103
+ request.startedAt = performance.now();
1104
+ this.addEventListener("loadend", () => {
1105
+ if (shouldTrackRequestUrl(request.url)) {
1106
+ scheduleApiEventRecord({
1107
+ method: request.method,
1108
+ url: request.url,
1109
+ status: this.status || "unknown",
1110
+ durationMs: performance.now() - request.startedAt,
1111
+ startedAt: request.startedAt
1112
+ });
1113
+ }
1114
+ });
1115
+ }
1116
+ return originalSend.apply(this, arguments);
1117
+ };
1118
+ }
1119
+
1120
+ function scheduleApiEventRecord(event) {
1121
+ window.setTimeout(() => recordApiEvent(event), 0);
1122
+ }
1123
+
1124
+ function recordApiEvent(event) {
1125
+ const normalized = normalizeUrl(event.url);
1126
+ if (!normalized || shouldIgnoreRequest(normalized)) {
1127
+ return;
1128
+ }
1129
+
1130
+ const matched = matchEndpoint(event.method, normalized.pathname);
1131
+ const passive = isPassiveRequest(event.method, normalized.pathname);
1132
+ const triggeredBy = passive ? null : findActionForRequest(event.startedAt);
1133
+ const item = {
1134
+ id: String(Date.now()) + "-" + Math.random().toString(36).slice(2),
1135
+ method: event.method,
1136
+ path: normalized.pathname,
1137
+ status: event.status,
1138
+ durationMs: Math.round(event.durationMs),
1139
+ count: 1,
1140
+ lastSeenAt: Date.now(),
1141
+ triggeredBy,
1142
+ source: triggeredBy ? "action" : classifyApiEventSource(normalized.pathname),
1143
+ matchedEndpoint: matched.endpoint,
1144
+ matchedFlow: matched.flow,
1145
+ matchedPages: matched.pages
1146
+ };
1147
+
1148
+ const existingIndex = findExistingEventIndex(item);
1149
+ if (existingIndex >= 0) {
1150
+ const existing = state.events[existingIndex];
1151
+ const updated = Object.assign({}, existing, {
1152
+ status: item.status,
1153
+ durationMs: item.durationMs,
1154
+ count: (existing.count || 1) + 1,
1155
+ lastSeenAt: item.lastSeenAt,
1156
+ triggeredBy: item.triggeredBy || existing.triggeredBy,
1157
+ source: item.triggeredBy ? "action" : item.source,
1158
+ matchedEndpoint: item.matchedEndpoint,
1159
+ matchedFlow: item.matchedFlow,
1160
+ matchedPages: item.matchedPages
1161
+ });
1162
+ state.events = [updated].concat(state.events.filter((_, index) => index !== existingIndex)).slice(0, 12);
1163
+ if (shouldAutoFocusEvent(updated)) {
1164
+ brieflyExpandLauncher();
1165
+ state.selectedEventId = updated.id;
1166
+ state.open = true;
1167
+ }
1168
+ render();
1169
+ return;
1170
+ }
1171
+
1172
+ state.events = [item].concat(state.events).slice(0, 12);
1173
+ if (shouldAutoFocusEvent(item)) {
1174
+ brieflyExpandLauncher();
1175
+ state.selectedEventId = item.id;
1176
+ state.open = true;
1177
+ }
1178
+ render();
1179
+ }
1180
+
1181
+ function shouldAutoFocusEvent(item) {
1182
+ return Boolean(item && item.triggeredBy);
1183
+ }
1184
+
1185
+ function brieflyExpandLauncher() {
1186
+ launcherSettings.expandedUntil = Date.now() + 2600;
1187
+ applyLauncherSettings();
1188
+ window.setTimeout(applyLauncherSettings, 2700);
1189
+ }
1190
+
1191
+ function classifyApiEventSource(pathname) {
1192
+ if (isHealthOrPollingPath(pathname)) {
1193
+ return "health";
1194
+ }
1195
+ return "background";
1196
+ }
1197
+
1198
+ function isPassiveRequest(method, pathname) {
1199
+ const normalizedMethod = String(method || "GET").toUpperCase();
1200
+ const segments = getPathSegments(pathname);
1201
+ if (isHealthOrPollingPath(pathname)) {
1202
+ return true;
1203
+ }
1204
+ if (segments.some((segment) => {
1205
+ return segment === "page-views" ||
1206
+ segment === "analytics" ||
1207
+ segment === "telemetry" ||
1208
+ segment === "events" ||
1209
+ segment === "metrics";
1210
+ })) {
1211
+ return true;
1212
+ }
1213
+ if (isAutomaticSupportPath(normalizedMethod, segments)) {
1214
+ return true;
1215
+ }
1216
+ return false;
1217
+ }
1218
+
1219
+ function isAutomaticSupportPath(method, segments) {
1220
+ if (method === "GET" && isSessionProbePath(segments)) {
1221
+ return true;
1222
+ }
1223
+ if (segments.includes("csrf") || segments.includes("xsrf")) {
1224
+ return true;
1225
+ }
1226
+ if (!segments.includes("auth")) {
1227
+ return false;
1228
+ }
1229
+ const last = segments[segments.length - 1] || "";
1230
+ return last === "session" ||
1231
+ last === "refresh" ||
1232
+ last === "token" ||
1233
+ last === "csrf" ||
1234
+ last === "status";
1235
+ }
1236
+
1237
+ function isSessionProbePath(segments) {
1238
+ const last = segments[segments.length - 1] || "";
1239
+ if (last === "me" || last === "session" || last === "profile" || last === "current-user") {
1240
+ return true;
1241
+ }
1242
+ return segments.includes("saved-benefits") ||
1243
+ segments.includes("saved-items") ||
1244
+ segments.includes("bookmarks") ||
1245
+ segments.includes("favorites");
1246
+ }
1247
+
1248
+ function isHealthOrPollingPath(pathname) {
1249
+ const segments = getPathSegments(pathname);
1250
+ return segments.some((segment) => {
1251
+ return segment === "health" ||
1252
+ segment === "healthz" ||
1253
+ segment === "ready" ||
1254
+ segment === "readyz" ||
1255
+ segment === "live" ||
1256
+ segment === "livez" ||
1257
+ segment === "ping" ||
1258
+ segment === "metrics" ||
1259
+ segment === "poll" ||
1260
+ segment === "polling";
1261
+ });
1262
+ }
1263
+
1264
+ function getPathSegments(pathname) {
1265
+ return String(pathname || "").toLowerCase().split("/").filter(Boolean);
1266
+ }
1267
+
1268
+ function findExistingEventIndex(item) {
1269
+ return state.events.findIndex((event) => {
1270
+ return event.method === item.method &&
1271
+ event.path === item.path &&
1272
+ String(event.status) === String(item.status) &&
1273
+ getEndpointId(event.matchedEndpoint) === getEndpointId(item.matchedEndpoint);
1274
+ });
1275
+ }
1276
+
1277
+ function getEndpointId(endpoint) {
1278
+ return endpoint && endpoint.id ? endpoint.id : "";
1279
+ }
1280
+
1281
+ function findActionForRequest(startedAt) {
1282
+ const requestStartedAt = Number(startedAt || performance.now());
1283
+ return state.actions.find((action) => {
1284
+ const age = requestStartedAt - action.at;
1285
+ if (age >= -50 && age <= 3000) {
1286
+ return true;
1287
+ }
1288
+ return Date.now() - action.capturedAt <= 8000;
1289
+ }) || null;
1290
+ }
1291
+
1292
+ function normalizeUrl(value) {
1293
+ if (!value) {
1294
+ return null;
1295
+ }
1296
+ try {
1297
+ return new URL(String(value), window.location.href);
1298
+ } catch {
1299
+ return null;
1300
+ }
1301
+ }
1302
+
1303
+ function shouldIgnoreRequest(url) {
1304
+ if (
1305
+ url.pathname.startsWith("/_anlyx/") ||
1306
+ url.pathname.startsWith("/@vite/") ||
1307
+ url.pathname.startsWith("/_next/") ||
1308
+ url.pathname.startsWith("/getconfig/") ||
1309
+ url.pathname.includes("hot-update") ||
1310
+ url.pathname === "/favicon.ico"
1311
+ ) {
1312
+ return true;
1313
+ }
1314
+ return /\.(css|js|map|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf)$/i.test(url.pathname);
1315
+ }
1316
+
1317
+ function shouldTrackRequestUrl(value) {
1318
+ const normalized = normalizeUrl(value);
1319
+ return Boolean(normalized && !shouldIgnoreRequest(normalized));
1320
+ }
1321
+
1322
+ function matchEndpoint(method, path) {
1323
+ const report = state.report || {};
1324
+ const endpoints = Array.isArray(report.endpoints) ? report.endpoints : [];
1325
+ const endpoint = endpoints.find((candidate) => {
1326
+ return String(candidate.method || "").toUpperCase() === method && endpointPathToRegex(candidate.path).test(path);
1327
+ });
1328
+ const flows = Array.isArray(report.flows) ? report.flows : [];
1329
+ const pages = Array.isArray(report.pages) ? report.pages : [];
1330
+ return {
1331
+ endpoint,
1332
+ flow: endpoint ? flows.find((flow) => flow.endpointId === endpoint.id) : null,
1333
+ pages: endpoint
1334
+ ? pages.filter((page) => Array.isArray(page.apiCalls) && page.apiCalls.some((call) => call.endpointId === endpoint.id))
1335
+ : []
1336
+ };
1337
+ }
1338
+
1339
+ function endpointPathToRegex(path) {
1340
+ const key = String(path || "");
1341
+ const cached = endpointRegexCache.get(key);
1342
+ if (cached) {
1343
+ return cached;
1344
+ }
1345
+ const escaped = String(path || "")
1346
+ .replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
1347
+ .replace(/\\\{[^/]+\\\}/g, "[^/]+");
1348
+ const regex = new RegExp("^" + escaped + "$");
1349
+ endpointRegexCache.set(key, regex);
1350
+ return regex;
1351
+ }
1352
+
1353
+ function render() {
1354
+ if (!drawer || !body) {
1355
+ return;
1356
+ }
1357
+
1358
+ drawer.dataset.open = state.open ? "true" : "false";
1359
+ if (!state.open) {
1360
+ return;
1361
+ }
1362
+
1363
+ const selected = state.events.find((event) => event.id === state.selectedEventId) || null;
1364
+ renderReactDrawer(selected, getLatestAction());
1365
+
1366
+ installEventSelectionHandler();
1367
+ }
1368
+
1369
+ function installEventSelectionHandler() {
1370
+ if (!body || body.dataset.eventSelectionBound === "true") {
1371
+ return;
1372
+ }
1373
+ body.dataset.eventSelectionBound = "true";
1374
+ const selectEventFromTarget = (target) => {
1375
+ const row = target && target.closest ? target.closest("[data-event-id]") : null;
1376
+ if (!row) {
1377
+ return false;
1378
+ }
1379
+ state.selectedEventId = row.getAttribute("data-event-id");
1380
+ state.open = true;
1381
+ render();
1382
+ return true;
1383
+ };
1384
+ body.addEventListener("click", (event) => {
1385
+ selectEventFromTarget(event.target);
1386
+ });
1387
+ body.addEventListener("keydown", (event) => {
1388
+ if (event.key !== "Enter" && event.key !== " ") {
1389
+ return;
1390
+ }
1391
+ if (selectEventFromTarget(event.target)) {
1392
+ event.preventDefault();
1393
+ }
1394
+ });
1395
+ }
1396
+
1397
+ function getLatestAction() {
1398
+ return state.actions.find((action) => isFreshAction(action)) || null;
1399
+ }
1400
+
1401
+ function getScannedHints() {
1402
+ const report = state.report || {};
1403
+ const pages = Array.isArray(report.pages) ? report.pages : [];
1404
+ const endpoints = Array.isArray(report.endpoints) ? report.endpoints : [];
1405
+ const pathname = window.location && window.location.pathname ? window.location.pathname : "/";
1406
+ const matchingPages = pages.filter((page) => {
1407
+ return page && page.route && routeToRegex(page.route).test(pathname);
1408
+ });
1409
+
1410
+ return matchingPages.flatMap((page) => {
1411
+ const apiCalls = Array.isArray(page.apiCalls) ? page.apiCalls : [];
1412
+ return apiCalls.map((apiCall) => {
1413
+ const endpoint = apiCall.endpointId
1414
+ ? endpoints.find((candidate) => candidate && candidate.id === apiCall.endpointId)
1415
+ : null;
1416
+ const method = String(apiCall.method || (endpoint && endpoint.method) || "GET").toUpperCase();
1417
+ const path = String(apiCall.path || (endpoint && endpoint.path) || "");
1418
+ return {
1419
+ pageRoute: page.route,
1420
+ pageFilePath: page.filePath,
1421
+ method,
1422
+ path,
1423
+ endpointId: apiCall.endpointId || (endpoint && endpoint.id),
1424
+ endpointLabel: endpoint ? String(endpoint.method || method).toUpperCase() + " " + endpoint.path : method + " " + path,
1425
+ evidence: page.captureStatus === "success" ? "capture" : "scanned-page"
1426
+ };
1427
+ });
1428
+ }).filter((hint) => hint.path).slice(0, 4);
1429
+ }
1430
+
1431
+ function routeToRegex(route) {
1432
+ const escaped = String(route || "/")
1433
+ .replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
1434
+ .replace(/\\\[\\\.\\\.\\\.[^\]]+\\\]/g, ".+")
1435
+ .replace(/\\\[\\\[\\\.\\\.\\\.[^\]]+\\\]\\\]/g, ".*")
1436
+ .replace(/\\\[[^\]]+\\\]/g, "[^/]+");
1437
+ return new RegExp("^" + escaped + "/?$");
1438
+ }
1439
+
1440
+ function renderReactDrawer(selected, latestAction) {
1441
+ loadOverlayUiAssets();
1442
+
1443
+ if (!window.__ANLYX_RENDER_FLOW_DRAWER__) {
1444
+ body.innerHTML = '<section class="anlyx-section"><h3 class="anlyx-section-title">Loading</h3><div class="anlyx-empty">Loading Anlyx Flow Drawer...</div></section>';
1445
+ return;
1446
+ }
1447
+
1448
+ window.__ANLYX_RENDER_FLOW_DRAWER__(body, {
1449
+ selectedEvent: selected,
1450
+ events: state.events,
1451
+ latestAction,
1452
+ scannedHints: getScannedHints(),
1453
+ loadError: state.loadError,
1454
+ runtimeBaseUrl
1455
+ });
1456
+ }
1457
+ })();
1458
+ `;
1459
+ }
112
1460
  function withDefaultDependencies(dependencies) {
113
1461
  return {
114
1462
  loadConfig: dependencies?.loadConfig ?? loadConfig,
115
1463
  readReportData: dependencies?.readReportData ?? readReportData,
1464
+ runScanCommand: dependencies?.runScanCommand ?? runScanCommand,
116
1465
  createLocalUiServer: dependencies?.createLocalUiServer ?? createLocalUiServer,
1466
+ isFrontendReachable: dependencies?.isFrontendReachable ?? isFrontendReachable,
1467
+ startFrontendDevServer: dependencies?.startFrontendDevServer ?? startFrontendDevServer,
117
1468
  openBrowser: dependencies?.openBrowser ?? openBrowser
118
1469
  };
119
1470
  }