anlyx 0.1.3 → 0.1.6-beta.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.
@@ -3,27 +3,27 @@ import { readFile } from "node:fs/promises";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { scanResultSchema } from "@anlyx/core";
6
+ import { buildFlowRecordFromBrowserEvent, mergeBackendSpansIntoFlowRecord, normalizeProjectInput, parseProjectValidationReport, parseProjectData, 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";
10
9
  const require = createRequire(import.meta.url);
11
10
  const activeLocalUiServers = new Set();
12
11
  export async function runDevCommand(options = {}) {
13
12
  const cwd = resolve(options.cwd ?? process.cwd());
14
13
  const dependencies = withDefaultDependencies(options.dependencies);
15
- const config = await dependencies.loadConfig({
14
+ const config = await loadConfigOrViewerFallback(dependencies, {
16
15
  cwd,
17
16
  ...(options.configPath ? { configPath: options.configPath } : {})
18
17
  });
19
18
  const outputDir = resolve(cwd, options.outputDir ?? ".anlyx");
19
+ const projectDataPath = join(cwd, "anlyx.project.json");
20
+ const splitProjectDataPath = join(outputDir, "project", "index.json");
20
21
  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 } : {})
22
+ const validationReportPath = join(outputDir, "validation-report.json");
23
+ const loadedData = await ensureProjectData({
24
+ projectDataPath,
25
+ splitProjectDataPath,
26
+ dependencies
27
27
  });
28
28
  const frontendStarted = await ensureFrontendDevServer({
29
29
  cwd,
@@ -31,13 +31,18 @@ export async function runDevCommand(options = {}) {
31
31
  dependencies
32
32
  });
33
33
  const port = options.port ?? getConfiguredPort(config);
34
- const server = await dependencies.createLocalUiServer({
34
+ const validationReport = await dependencies.readValidationReport(validationReportPath);
35
+ const serverOptions = {
35
36
  port,
36
- reportData,
37
+ projectData: loadedData.data,
37
38
  viewerRoot: getViewerRoot(),
38
39
  frontendBaseUrl: config.frontend.baseUrl,
39
40
  mode: config.server.mode
40
- });
41
+ };
42
+ if (validationReport) {
43
+ serverOptions.validationReport = validationReport;
44
+ }
45
+ const server = await dependencies.createLocalUiServer(serverOptions);
41
46
  activeLocalUiServers.add(server);
42
47
  const shouldOpenBrowser = options.open ?? config.server.openBrowser;
43
48
  const browserUrl = config.server.mode === "inject" ? config.frontend.baseUrl : server.url;
@@ -47,14 +52,47 @@ export async function runDevCommand(options = {}) {
47
52
  return {
48
53
  url: server.url,
49
54
  port,
55
+ projectDataPath: loadedData.path,
50
56
  reportDataPath,
51
57
  mode: config.server.mode,
52
58
  frontendStarted,
53
- scanRan,
54
59
  ...(config.server.mode === "inject" ? { frontendUrl: config.frontend.baseUrl } : {}),
55
60
  ...(config.server.mode === "inject" ? { scriptTag: getOverlayScriptTag(server.url) } : {})
56
61
  };
57
62
  }
63
+ async function loadConfigOrViewerFallback(dependencies, options) {
64
+ try {
65
+ return await dependencies.loadConfig(options);
66
+ }
67
+ catch (error) {
68
+ if (!isMissingConfigError(error)) {
69
+ throw error;
70
+ }
71
+ return createViewerFallbackConfig();
72
+ }
73
+ }
74
+ function createViewerFallbackConfig() {
75
+ return {
76
+ projectName: "Anlyx Project JSON",
77
+ backend: {
78
+ type: "openapi",
79
+ openApiUrl: "about:blank"
80
+ },
81
+ frontend: {
82
+ type: "manual",
83
+ baseUrl: "http://localhost:4777",
84
+ urls: [],
85
+ viewport: { width: 1440, height: 900 },
86
+ capture: { mode: "segments", segmentHeight: 900 }
87
+ },
88
+ server: {
89
+ port: 4777,
90
+ openBrowser: true,
91
+ mode: "viewer"
92
+ },
93
+ dev: {}
94
+ };
95
+ }
58
96
  export async function closeActiveLocalUiServers() {
59
97
  const servers = Array.from(activeLocalUiServers);
60
98
  activeLocalUiServers.clear();
@@ -62,33 +100,21 @@ export async function closeActiveLocalUiServers() {
62
100
  await server.close?.();
63
101
  }));
64
102
  }
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;
103
+ async function ensureProjectData(options) {
104
+ for (const path of [options.projectDataPath, options.splitProjectDataPath]) {
105
+ try {
106
+ return {
107
+ data: await options.dependencies.readProjectData(path),
108
+ path
109
+ };
110
+ }
111
+ catch (error) {
112
+ if (!isMissingProjectDataError(error)) {
113
+ throw error;
114
+ }
75
115
  }
76
116
  }
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
- };
117
+ throw new Error('Anlyx project data not found. Create or import "anlyx.project.json" first.');
92
118
  }
93
119
  async function ensureFrontendDevServer(options) {
94
120
  const command = options.config.dev?.command;
@@ -104,8 +130,93 @@ async function ensureFrontendDevServer(options) {
104
130
  });
105
131
  return true;
106
132
  }
107
- function isMissingReportDataError(error) {
108
- return error instanceof Error && /Run "anlyx scan" first/.test(error.message);
133
+ function isMissingProjectDataError(error) {
134
+ return error instanceof Error && /Anlyx project data not found/.test(error.message);
135
+ }
136
+ function isMissingConfigError(error) {
137
+ return error instanceof Error && /Anlyx config file not found/.test(error.message);
138
+ }
139
+ export async function readProjectData(path) {
140
+ let content;
141
+ try {
142
+ content = await readFile(path, "utf8");
143
+ }
144
+ catch (error) {
145
+ if (isNodeError(error) && error.code === "ENOENT") {
146
+ throw new Error(`Anlyx project data not found: ${path}. Create anlyx.project.json first.`);
147
+ }
148
+ throw error;
149
+ }
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(content);
153
+ }
154
+ catch (error) {
155
+ throw new Error(`Failed to parse Anlyx project data JSON at ${path}: ${error instanceof Error ? error.message : "invalid JSON"}`);
156
+ }
157
+ try {
158
+ if (path.endsWith("/index.json")) {
159
+ return await readSplitProjectData(path, parsed);
160
+ }
161
+ return normalizeProjectInput(parseProjectData(parsed));
162
+ }
163
+ catch (error) {
164
+ throw new Error(`Invalid Anlyx project data at ${path}: ${error instanceof Error ? error.message : "invalid project data"}`);
165
+ }
166
+ }
167
+ async function readSplitProjectData(indexPath, index) {
168
+ const projectDir = dirname(indexPath);
169
+ const [areas, pages, features, requests, flows, architecture, evidence, measurements, dictionary, coverage, overview, capabilities, dataLifecycles, impactMaps] = await Promise.all([
170
+ readOptionalProjectJson(join(projectDir, "areas.json")),
171
+ readOptionalProjectJson(join(projectDir, "pages.json")),
172
+ readOptionalProjectJson(join(projectDir, "features.json")),
173
+ readOptionalProjectJson(join(projectDir, "requests.json")),
174
+ readOptionalProjectJson(join(projectDir, "flows.json")),
175
+ readOptionalProjectJson(join(projectDir, "architecture.json")),
176
+ readOptionalProjectJson(join(projectDir, "evidence.json")),
177
+ readOptionalProjectJson(join(projectDir, "measurements.json")),
178
+ readOptionalProjectJson(join(projectDir, "dictionary.json")),
179
+ readOptionalProjectJson(join(projectDir, "coverage.json")),
180
+ readOptionalProjectJson(join(projectDir, "overview.json")),
181
+ readOptionalProjectJson(join(projectDir, "capabilities.json")),
182
+ readOptionalProjectJson(join(projectDir, "data-lifecycles.json")),
183
+ readOptionalProjectJson(join(projectDir, "impact-maps.json"))
184
+ ]);
185
+ return normalizeProjectInput({
186
+ index,
187
+ ...(areas === undefined ? {} : { areas }),
188
+ ...(pages === undefined ? {} : { pages }),
189
+ ...(features === undefined ? {} : { features }),
190
+ ...(requests === undefined ? {} : { requests }),
191
+ ...(flows === undefined ? {} : { flows }),
192
+ ...(architecture === undefined ? {} : { architecture }),
193
+ ...(evidence === undefined ? {} : { evidence }),
194
+ ...(measurements === undefined ? {} : { measurements }),
195
+ ...(dictionary === undefined ? {} : { dictionary }),
196
+ ...(coverage === undefined ? {} : { coverage }),
197
+ ...(overview === undefined ? {} : { overview }),
198
+ ...(capabilities === undefined ? {} : { capabilities }),
199
+ ...(dataLifecycles === undefined ? {} : { dataLifecycles }),
200
+ ...(impactMaps === undefined ? {} : { impactMaps })
201
+ });
202
+ }
203
+ async function readOptionalProjectJson(path) {
204
+ let content;
205
+ try {
206
+ content = await readFile(path, "utf8");
207
+ }
208
+ catch (error) {
209
+ if (isNodeError(error) && error.code === "ENOENT") {
210
+ return undefined;
211
+ }
212
+ throw error;
213
+ }
214
+ try {
215
+ return JSON.parse(content);
216
+ }
217
+ catch (error) {
218
+ throw new Error(`Failed to parse split Anlyx project JSON at ${path}: ${error instanceof Error ? error.message : "invalid JSON"}`);
219
+ }
109
220
  }
110
221
  export async function readReportData(path) {
111
222
  let content;
@@ -114,7 +225,7 @@ export async function readReportData(path) {
114
225
  }
115
226
  catch (error) {
116
227
  if (isNodeError(error) && error.code === "ENOENT") {
117
- throw new Error(`Anlyx report data not found: ${path}. Run "anlyx scan" first.`);
228
+ throw new Error(`Anlyx report data not found: ${path}. Create or import "anlyx.project.json" first.`);
118
229
  }
119
230
  throw error;
120
231
  }
@@ -137,6 +248,26 @@ export async function readReportData(path) {
137
248
  }
138
249
  return result.data;
139
250
  }
251
+ export async function readValidationReport(path) {
252
+ let content;
253
+ try {
254
+ content = await readFile(path, "utf8");
255
+ }
256
+ catch (error) {
257
+ if (isNodeError(error) && error.code === "ENOENT") {
258
+ return undefined;
259
+ }
260
+ throw error;
261
+ }
262
+ let parsed;
263
+ try {
264
+ parsed = JSON.parse(content);
265
+ }
266
+ catch (error) {
267
+ throw new Error(`Failed to parse Anlyx validation report JSON at ${path}: ${error instanceof Error ? error.message : "invalid JSON"}`);
268
+ }
269
+ return parseProjectValidationReport(parsed);
270
+ }
140
271
  export async function createLocalUiServer(options) {
141
272
  const viteServer = await createServer({
142
273
  root: options.viewerRoot,
@@ -194,11 +325,12 @@ export function startFrontendDevServer(options) {
194
325
  };
195
326
  }
196
327
  function createAnlyxDevPlugin(options) {
328
+ const liveEvents = options.reportData ? createLiveEventRuntime(options.reportData) : undefined;
197
329
  return {
198
330
  name: "anlyx-dev-runtime",
199
331
  configureServer(server) {
200
332
  server.middlewares.use((request, response, next) => {
201
- if (request.method === "GET" && options.mode === "viewer" && request.url === "/") {
333
+ if (isReadRequest(request.method) && options.mode === "viewer" && request.url === "/") {
202
334
  request.url = "/viewer.html";
203
335
  }
204
336
  next();
@@ -211,10 +343,62 @@ function createAnlyxDevPlugin(options) {
211
343
  response.end();
212
344
  return;
213
345
  }
346
+ if (request.method === "GET" && isProjectDataPath(requestUrl)) {
347
+ if (!options.projectData) {
348
+ sendJsonError(response, 404, new Error("Anlyx project data not loaded."));
349
+ return;
350
+ }
351
+ sendJson(response, options.projectData);
352
+ return;
353
+ }
214
354
  if (request.method === "GET" && isReportDataPath(requestUrl)) {
355
+ if (!options.reportData) {
356
+ sendJsonError(response, 404, new Error("Legacy Anlyx report data not loaded."));
357
+ return;
358
+ }
215
359
  sendJson(response, options.reportData);
216
360
  return;
217
361
  }
362
+ if (request.method === "GET" && isValidationReportPath(requestUrl)) {
363
+ if (!options.validationReport) {
364
+ sendJsonError(response, 404, new Error("Anlyx validation report not loaded."));
365
+ return;
366
+ }
367
+ sendJson(response, options.validationReport);
368
+ return;
369
+ }
370
+ if (request.method === "GET" && requestUrl === "/_anlyx/events/stream") {
371
+ if (!liveEvents) {
372
+ sendJsonError(response, 404, new Error("Legacy live events are not available for project data."));
373
+ return;
374
+ }
375
+ liveEvents.openStream(request, response);
376
+ return;
377
+ }
378
+ if (request.method === "POST" && requestUrl === "/_anlyx/events/browser-request") {
379
+ if (!liveEvents) {
380
+ sendJsonError(response, 404, new Error("Legacy live events are not available for project data."));
381
+ return;
382
+ }
383
+ await liveEvents.recordBrowserRequest(request, response);
384
+ return;
385
+ }
386
+ if (request.method === "POST" && requestUrl === "/_anlyx/events/page-context") {
387
+ if (!liveEvents) {
388
+ sendJsonError(response, 404, new Error("Legacy live events are not available for project data."));
389
+ return;
390
+ }
391
+ await liveEvents.recordPageContext(request, response);
392
+ return;
393
+ }
394
+ if (request.method === "POST" && requestUrl === "/_anlyx/events/backend-spans") {
395
+ if (!liveEvents) {
396
+ sendJsonError(response, 404, new Error("Legacy live events are not available for project data."));
397
+ return;
398
+ }
399
+ await liveEvents.recordBackendSpans(request, response);
400
+ return;
401
+ }
218
402
  if (request.method === "GET" && requestUrl === "/_anlyx/overlay.js") {
219
403
  response.statusCode = 200;
220
404
  setCorsHeaders(response);
@@ -236,7 +420,7 @@ function createAnlyxDevPlugin(options) {
236
420
  response.end(getInjectModeHtml(options.frontendBaseUrl, getOverlayScriptTag(getServerUrl(options.port))));
237
421
  return;
238
422
  }
239
- if (request.method === "GET" && isStandaloneViewerPath(requestUrl)) {
423
+ if (isReadRequest(request.method) && isStandaloneViewerPath(requestUrl)) {
240
424
  request.url = "/viewer.html";
241
425
  next();
242
426
  return;
@@ -256,12 +440,140 @@ function createAnlyxDevPlugin(options) {
256
440
  }
257
441
  };
258
442
  }
443
+ export function createLiveEventRuntime(reportData) {
444
+ const streams = new Set();
445
+ const recordsByRequestId = new Map();
446
+ const pendingSpansByRequestId = new Map();
447
+ let latestPageContext;
448
+ let recentRecords = [];
449
+ const rememberRecord = (record) => {
450
+ recordsByRequestId.set(record.requestId, record);
451
+ recentRecords = [record, ...recentRecords.filter((item) => item.id !== record.id)].slice(0, 90);
452
+ };
453
+ const broadcastRecord = (record) => {
454
+ rememberRecord(record);
455
+ for (const stream of streams) {
456
+ writeFlowSseEvent(stream, record);
457
+ }
458
+ };
459
+ const broadcastPageContext = (event) => {
460
+ latestPageContext = event;
461
+ for (const stream of streams) {
462
+ writePageContextSseEvent(stream, event);
463
+ }
464
+ };
465
+ return {
466
+ openStream(request, response) {
467
+ response.statusCode = 200;
468
+ setCorsHeaders(response);
469
+ response.setHeader("content-type", "text/event-stream; charset=utf-8");
470
+ response.setHeader("cache-control", "no-cache, no-transform");
471
+ response.setHeader("connection", "keep-alive");
472
+ response.write(": Anlyx event stream ready\n\n");
473
+ streams.add(response);
474
+ if (latestPageContext) {
475
+ writePageContextSseEvent(response, latestPageContext);
476
+ }
477
+ for (const record of [...recentRecords].reverse()) {
478
+ writeFlowSseEvent(response, record);
479
+ }
480
+ const heartbeat = setInterval(() => {
481
+ response.write(": heartbeat\n\n");
482
+ }, 30000);
483
+ request.on("close", () => {
484
+ clearInterval(heartbeat);
485
+ streams.delete(response);
486
+ response.end();
487
+ });
488
+ },
489
+ async recordBrowserRequest(request, response) {
490
+ let payload;
491
+ try {
492
+ payload = await readJsonRequestBody(request);
493
+ }
494
+ catch (error) {
495
+ sendJsonError(response, 400, error);
496
+ return;
497
+ }
498
+ if (!isBrowserRequestEvent(payload)) {
499
+ sendJsonError(response, 400, new Error("Invalid Anlyx browser request event payload."));
500
+ return;
501
+ }
502
+ const event = withRequestPageContext(payload, request, latestPageContext);
503
+ if (event.pageUrl) {
504
+ latestPageContext = {
505
+ type: "page_context",
506
+ pageUrl: event.pageUrl,
507
+ contextId: event.contextId ?? pageContextId(event.pageUrl),
508
+ observedAt: event.observedAt
509
+ };
510
+ }
511
+ let record = buildFlowRecordFromBrowserEvent(event, reportData);
512
+ const pendingSpans = pendingSpansByRequestId.get(event.id);
513
+ if (pendingSpans) {
514
+ pendingSpansByRequestId.delete(event.id);
515
+ record = mergeBackendSpansIntoFlowRecord(record, pendingSpans);
516
+ }
517
+ broadcastRecord(record);
518
+ sendAccepted(response, { accepted: true, id: record.id });
519
+ },
520
+ async recordPageContext(request, response) {
521
+ let payload;
522
+ try {
523
+ payload = await readJsonRequestBody(request);
524
+ }
525
+ catch (error) {
526
+ sendJsonError(response, 400, error);
527
+ return;
528
+ }
529
+ if (!isPageContextEvent(payload)) {
530
+ sendJsonError(response, 400, new Error("Invalid Anlyx page context event payload."));
531
+ return;
532
+ }
533
+ broadcastPageContext(payload);
534
+ sendAccepted(response, { accepted: true, contextId: payload.contextId });
535
+ },
536
+ async recordBackendSpans(request, response) {
537
+ let payload;
538
+ try {
539
+ payload = await readJsonRequestBody(request);
540
+ }
541
+ catch (error) {
542
+ sendJsonError(response, 400, error);
543
+ return;
544
+ }
545
+ if (!isBackendSpanEvent(payload)) {
546
+ sendJsonError(response, 400, new Error("Invalid Anlyx backend span event payload."));
547
+ return;
548
+ }
549
+ const existingRecord = recordsByRequestId.get(payload.requestId);
550
+ if (!existingRecord) {
551
+ const existingSpans = pendingSpansByRequestId.get(payload.requestId) ?? [];
552
+ pendingSpansByRequestId.set(payload.requestId, [...existingSpans, ...payload.spans]);
553
+ sendAccepted(response, { accepted: true, pending: true, requestId: payload.requestId });
554
+ return;
555
+ }
556
+ const record = mergeBackendSpansIntoFlowRecord(existingRecord, payload.spans);
557
+ broadcastRecord(record);
558
+ sendAccepted(response, { accepted: true, id: record.id });
559
+ }
560
+ };
561
+ }
259
562
  function isReportDataPath(path) {
260
563
  return path === "/_anlyx/report-data" || path === "/api/report-data";
261
564
  }
565
+ function isProjectDataPath(path) {
566
+ return path === "/_anlyx/project-data" || path === "/api/project-data";
567
+ }
568
+ function isValidationReportPath(path) {
569
+ return path === "/_anlyx/validation-report" || path === "/api/validation-report";
570
+ }
262
571
  function isStandaloneViewerPath(path) {
263
572
  return path === "/_anlyx/viewer" || path === "/_anlyx/viewer.html";
264
573
  }
574
+ function isReadRequest(method) {
575
+ return method === "GET" || method === "HEAD";
576
+ }
265
577
  function isAnlyxPath(path) {
266
578
  return path.startsWith("/_anlyx/");
267
579
  }
@@ -271,6 +583,130 @@ function sendJson(response, value) {
271
583
  response.setHeader("content-type", "application/json; charset=utf-8");
272
584
  response.end(JSON.stringify(value));
273
585
  }
586
+ function sendAccepted(response, value) {
587
+ response.statusCode = 202;
588
+ setCorsHeaders(response);
589
+ response.setHeader("content-type", "application/json; charset=utf-8");
590
+ response.end(JSON.stringify(value));
591
+ }
592
+ function sendJsonError(response, statusCode, error) {
593
+ response.statusCode = statusCode;
594
+ setCorsHeaders(response);
595
+ response.setHeader("content-type", "application/json; charset=utf-8");
596
+ response.end(JSON.stringify({
597
+ error: error instanceof Error ? error.message : "Invalid Anlyx runtime event."
598
+ }));
599
+ }
600
+ async function readJsonRequestBody(request) {
601
+ const body = await readRequestBody(request);
602
+ if (!body || body.byteLength === 0) {
603
+ throw new Error("Missing JSON request body.");
604
+ }
605
+ try {
606
+ return JSON.parse(body.toString("utf8"));
607
+ }
608
+ catch (error) {
609
+ throw new Error(`Failed to parse Anlyx runtime event JSON: ${error instanceof Error ? error.message : "invalid JSON"}`);
610
+ }
611
+ }
612
+ function writeFlowSseEvent(response, record) {
613
+ response.write("event: flow\n");
614
+ response.write(`data: ${JSON.stringify(record)}\n\n`);
615
+ }
616
+ function writePageContextSseEvent(response, event) {
617
+ response.write("event: page-context\n");
618
+ response.write(`data: ${JSON.stringify(event)}\n\n`);
619
+ }
620
+ function withRequestPageContext(event, request, latestPageContext) {
621
+ const pageUrl = event.pageUrl ?? pageUrlFromReferer(request.headers.referer) ?? latestPageContext?.pageUrl;
622
+ if (!pageUrl) {
623
+ return event;
624
+ }
625
+ return {
626
+ ...event,
627
+ pageUrl,
628
+ contextId: event.contextId ?? latestPageContext?.contextId ?? pageContextId(pageUrl)
629
+ };
630
+ }
631
+ function pageUrlFromReferer(referer) {
632
+ const value = Array.isArray(referer) ? referer[0] : referer;
633
+ if (!value) {
634
+ return undefined;
635
+ }
636
+ try {
637
+ const parsed = new URL(value);
638
+ if (parsed.pathname.startsWith("/_anlyx/")) {
639
+ return undefined;
640
+ }
641
+ return `${parsed.origin}${parsed.pathname}${parsed.search}`;
642
+ }
643
+ catch {
644
+ return undefined;
645
+ }
646
+ }
647
+ function pageContextId(pageUrl) {
648
+ try {
649
+ const parsed = new URL(pageUrl);
650
+ return `page:${parsed.pathname}${parsed.search}`;
651
+ }
652
+ catch {
653
+ return `page:${pageUrl || "/"}`;
654
+ }
655
+ }
656
+ function isPageContextEvent(value) {
657
+ if (!value || typeof value !== "object") {
658
+ return false;
659
+ }
660
+ const event = value;
661
+ return (event.type === "page_context" &&
662
+ typeof event.pageUrl === "string" &&
663
+ typeof event.contextId === "string" &&
664
+ typeof event.observedAt === "string");
665
+ }
666
+ function isBrowserRequestEvent(value) {
667
+ if (!value || typeof value !== "object") {
668
+ return false;
669
+ }
670
+ const event = value;
671
+ return (event.type === "request" &&
672
+ typeof event.id === "string" &&
673
+ typeof event.method === "string" &&
674
+ typeof event.url === "string" &&
675
+ typeof event.observedAt === "string" &&
676
+ (event.path === undefined || typeof event.path === "string") &&
677
+ (event.pageUrl === undefined || typeof event.pageUrl === "string") &&
678
+ (event.status === undefined || typeof event.status === "number") &&
679
+ (event.durationMs === undefined || typeof event.durationMs === "number") &&
680
+ (event.contextId === undefined || typeof event.contextId === "string") &&
681
+ (event.priority === undefined ||
682
+ event.priority === "primary" ||
683
+ event.priority === "background") &&
684
+ (event.runtimeSource === undefined ||
685
+ event.runtimeSource === "browser" ||
686
+ event.runtimeSource === "server" ||
687
+ event.runtimeSource === "unknown"));
688
+ }
689
+ function isBackendSpanEvent(value) {
690
+ if (!value || typeof value !== "object") {
691
+ return false;
692
+ }
693
+ const event = value;
694
+ return (event.type === "backend_spans" &&
695
+ typeof event.requestId === "string" &&
696
+ Array.isArray(event.spans) &&
697
+ event.spans.every(isBackendObservedSpan));
698
+ }
699
+ function isBackendObservedSpan(value) {
700
+ if (!value || typeof value !== "object") {
701
+ return false;
702
+ }
703
+ const span = value;
704
+ return (typeof span.id === "string" &&
705
+ typeof span.type === "string" &&
706
+ typeof span.label === "string" &&
707
+ typeof span.startOffsetMs === "number" &&
708
+ typeof span.durationMs === "number");
709
+ }
274
710
  async function sendRuntimeAsset(response, path, contentType) {
275
711
  try {
276
712
  const content = await readFile(path);
@@ -288,8 +724,8 @@ async function sendRuntimeAsset(response, path, contentType) {
288
724
  }
289
725
  function setCorsHeaders(response) {
290
726
  response.setHeader("access-control-allow-origin", "*");
291
- response.setHeader("access-control-allow-methods", "GET, OPTIONS");
292
- response.setHeader("access-control-allow-headers", "content-type");
727
+ response.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
728
+ response.setHeader("access-control-allow-headers", "content-type, x-anlyx-request-id");
293
729
  }
294
730
  function getServerUrl(port) {
295
731
  return `http://localhost:${port}`;
@@ -660,6 +1096,7 @@ export function getOverlayClientScript() {
660
1096
  installUserActionTracker(root);
661
1097
  installFetchInterceptor();
662
1098
  installXhrInterceptor();
1099
+ installPageContextReporter();
663
1100
  loadReport();
664
1101
  }
665
1102
 
@@ -952,22 +1389,49 @@ export function getOverlayClientScript() {
952
1389
  window.fetch = async function anlyxFetch(input, init) {
953
1390
  const method = ((init && init.method) || (input && input.method) || "GET").toUpperCase();
954
1391
  const url = typeof input === "string" ? input : input && input.url;
1392
+ const shouldTrack = shouldTrackRequestUrl(url);
1393
+ const requestId = shouldTrack ? createRequestId() : null;
1394
+ const args = requestId ? withAnlyxFetchRequestId(input, init, requestId) : arguments;
955
1395
  const startedAt = performance.now();
956
1396
  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 });
1397
+ const response = await originalFetch.apply(this, args);
1398
+ if (shouldTrack) {
1399
+ scheduleApiEventRecord({ id: requestId, method, url, status: response.status, durationMs: performance.now() - startedAt, startedAt });
960
1400
  }
961
1401
  return response;
962
1402
  } catch (error) {
963
- if (shouldTrackRequestUrl(url)) {
964
- scheduleApiEventRecord({ method, url, status: "failed", durationMs: performance.now() - startedAt, startedAt });
1403
+ if (shouldTrack) {
1404
+ scheduleApiEventRecord({ id: requestId, method, url, status: "failed", durationMs: performance.now() - startedAt, startedAt });
965
1405
  }
966
1406
  throw error;
967
1407
  }
968
1408
  };
969
1409
  }
970
1410
 
1411
+ function createRequestId() {
1412
+ return "anlyx-" + Date.now() + "-" + Math.random().toString(36).slice(2);
1413
+ }
1414
+
1415
+ function withAnlyxFetchRequestId(input, init, requestId) {
1416
+ const nextInit = Object.assign({}, init || {});
1417
+ nextInit.headers = appendHeader(nextInit.headers || (input && input.headers), "X-Anlyx-Request-Id", requestId);
1418
+ return [input, nextInit];
1419
+ }
1420
+
1421
+ function appendHeader(headers, name, value) {
1422
+ try {
1423
+ if (typeof Headers !== "undefined") {
1424
+ const nextHeaders = new Headers(headers || {});
1425
+ nextHeaders.set(name, value);
1426
+ return nextHeaders;
1427
+ }
1428
+ } catch {
1429
+ // Fall through to a plain object header bag when Headers cannot clone input.
1430
+ }
1431
+
1432
+ return Object.assign({}, headers || {}, { [name]: value });
1433
+ }
1434
+
971
1435
  function installUserActionTracker(root) {
972
1436
  document.addEventListener("pointerdown", (event) => captureUserAction(event, root), true);
973
1437
  document.addEventListener("click", (event) => captureUserAction(event, root), true);
@@ -1093,7 +1557,13 @@ export function getOverlayClientScript() {
1093
1557
  const originalSend = XMLHttpRequest.prototype.send;
1094
1558
 
1095
1559
  XMLHttpRequest.prototype.open = function anlyxOpen(method, url) {
1096
- this.__anlyxRequest = { method: String(method || "GET").toUpperCase(), url: String(url || ""), startedAt: 0 };
1560
+ const requestUrl = String(url || "");
1561
+ this.__anlyxRequest = {
1562
+ id: shouldTrackRequestUrl(requestUrl) ? createRequestId() : null,
1563
+ method: String(method || "GET").toUpperCase(),
1564
+ url: requestUrl,
1565
+ startedAt: 0
1566
+ };
1097
1567
  return originalOpen.apply(this, arguments);
1098
1568
  };
1099
1569
 
@@ -1101,9 +1571,17 @@ export function getOverlayClientScript() {
1101
1571
  const request = this.__anlyxRequest;
1102
1572
  if (request) {
1103
1573
  request.startedAt = performance.now();
1574
+ if (request.id && this.setRequestHeader) {
1575
+ try {
1576
+ this.setRequestHeader("X-Anlyx-Request-Id", request.id);
1577
+ } catch {
1578
+ // Ignore header injection failures; the browser event can still be observed.
1579
+ }
1580
+ }
1104
1581
  this.addEventListener("loadend", () => {
1105
1582
  if (shouldTrackRequestUrl(request.url)) {
1106
1583
  scheduleApiEventRecord({
1584
+ id: request.id,
1107
1585
  method: request.method,
1108
1586
  url: request.url,
1109
1587
  status: this.status || "unknown",
@@ -1131,14 +1609,16 @@ export function getOverlayClientScript() {
1131
1609
  const passive = isPassiveRequest(event.method, normalized.pathname);
1132
1610
  const triggeredBy = passive ? null : findActionForRequest(event.startedAt);
1133
1611
  const item = {
1134
- id: String(Date.now()) + "-" + Math.random().toString(36).slice(2),
1612
+ id: event.id || createRequestId(),
1135
1613
  method: event.method,
1136
1614
  path: normalized.pathname,
1137
1615
  status: event.status,
1138
1616
  durationMs: Math.round(event.durationMs),
1139
1617
  count: 1,
1618
+ contextId: getCurrentContextId(),
1140
1619
  lastSeenAt: Date.now(),
1141
1620
  triggeredBy,
1621
+ priority: passive ? "background" : "primary",
1142
1622
  source: triggeredBy ? "action" : classifyApiEventSource(normalized.pathname),
1143
1623
  matchedEndpoint: matched.endpoint,
1144
1624
  matchedFlow: matched.flow,
@@ -1152,8 +1632,10 @@ export function getOverlayClientScript() {
1152
1632
  status: item.status,
1153
1633
  durationMs: item.durationMs,
1154
1634
  count: (existing.count || 1) + 1,
1635
+ contextId: item.contextId,
1155
1636
  lastSeenAt: item.lastSeenAt,
1156
1637
  triggeredBy: item.triggeredBy || existing.triggeredBy,
1638
+ priority: item.priority,
1157
1639
  source: item.triggeredBy ? "action" : item.source,
1158
1640
  matchedEndpoint: item.matchedEndpoint,
1159
1641
  matchedFlow: item.matchedFlow,
@@ -1166,6 +1648,7 @@ export function getOverlayClientScript() {
1166
1648
  state.open = true;
1167
1649
  }
1168
1650
  render();
1651
+ sendBrowserRequestEvent(updated);
1169
1652
  return;
1170
1653
  }
1171
1654
 
@@ -1176,12 +1659,124 @@ export function getOverlayClientScript() {
1176
1659
  state.open = true;
1177
1660
  }
1178
1661
  render();
1662
+ sendBrowserRequestEvent(item);
1663
+ }
1664
+
1665
+ function sendBrowserRequestEvent(item) {
1666
+ if (!window.fetch || !item || item.source === "anlyx") {
1667
+ return;
1668
+ }
1669
+
1670
+ const payload = {
1671
+ id: item.id,
1672
+ type: "request",
1673
+ method: item.method,
1674
+ url: item.path,
1675
+ path: item.path,
1676
+ pageUrl: getCurrentPageUrl(),
1677
+ status: typeof item.status === "number" ? item.status : undefined,
1678
+ durationMs: typeof item.durationMs === "number" ? item.durationMs : undefined,
1679
+ observedAt: new Date(item.lastSeenAt || Date.now()).toISOString(),
1680
+ contextId: item.contextId,
1681
+ priority: item.priority === "background" ? "background" : "primary",
1682
+ runtimeSource: "browser",
1683
+ action: item.triggeredBy ? {
1684
+ label: item.triggeredBy.label,
1685
+ selector: item.triggeredBy.selector,
1686
+ observedAt: new Date(item.triggeredBy.capturedAt || item.lastSeenAt || Date.now()).toISOString()
1687
+ } : undefined
1688
+ };
1689
+
1690
+ try {
1691
+ window.fetch(runtimeBaseUrl + "/_anlyx/events/browser-request", {
1692
+ method: "POST",
1693
+ headers: { "content-type": "application/json" },
1694
+ body: JSON.stringify(payload),
1695
+ keepalive: true
1696
+ }).catch(() => undefined);
1697
+ } catch {
1698
+ // Runtime ingestion is best-effort and must never break the user's app.
1699
+ }
1179
1700
  }
1180
1701
 
1181
1702
  function shouldAutoFocusEvent(item) {
1182
1703
  return Boolean(item && item.triggeredBy);
1183
1704
  }
1184
1705
 
1706
+ function getCurrentContextId() {
1707
+ const path = window.location && window.location.pathname ? window.location.pathname : "/";
1708
+ const search = window.location && window.location.search ? window.location.search : "";
1709
+ return "page:" + path + search;
1710
+ }
1711
+
1712
+ function getCurrentPageUrl() {
1713
+ if (!window.location) {
1714
+ return "/";
1715
+ }
1716
+
1717
+ return window.location.href.split("#")[0] || "/";
1718
+ }
1719
+
1720
+ function installPageContextReporter() {
1721
+ let lastPageUrl = "";
1722
+ let timer = null;
1723
+
1724
+ const schedulePageContext = () => {
1725
+ if (timer !== null) {
1726
+ window.clearTimeout(timer);
1727
+ }
1728
+ timer = window.setTimeout(() => {
1729
+ timer = null;
1730
+ sendPageContextEvent();
1731
+ }, 0);
1732
+ };
1733
+
1734
+ const wrapHistoryMethod = (methodName) => {
1735
+ const original = window.history && window.history[methodName];
1736
+ if (!original || original.__anlyxWrapped) {
1737
+ return;
1738
+ }
1739
+
1740
+ const wrapped = function anlyxHistoryWrapper() {
1741
+ const result = original.apply(this, arguments);
1742
+ schedulePageContext();
1743
+ return result;
1744
+ };
1745
+ wrapped.__anlyxWrapped = true;
1746
+ window.history[methodName] = wrapped;
1747
+ };
1748
+
1749
+ const sendPageContextEvent = () => {
1750
+ const pageUrl = getCurrentPageUrl();
1751
+ if (!pageUrl || pageUrl === lastPageUrl) {
1752
+ return;
1753
+ }
1754
+ lastPageUrl = pageUrl;
1755
+
1756
+ try {
1757
+ window.fetch(runtimeBaseUrl + "/_anlyx/events/page-context", {
1758
+ method: "POST",
1759
+ headers: { "content-type": "application/json" },
1760
+ body: JSON.stringify({
1761
+ type: "page_context",
1762
+ pageUrl,
1763
+ contextId: getCurrentContextId(),
1764
+ observedAt: new Date().toISOString()
1765
+ }),
1766
+ keepalive: true
1767
+ }).catch(() => undefined);
1768
+ } catch {
1769
+ // Page context reporting is best-effort and must never affect the app.
1770
+ }
1771
+ };
1772
+
1773
+ wrapHistoryMethod("pushState");
1774
+ wrapHistoryMethod("replaceState");
1775
+ window.addEventListener("popstate", schedulePageContext);
1776
+ window.addEventListener("hashchange", schedulePageContext);
1777
+ sendPageContextEvent();
1778
+ }
1779
+
1185
1780
  function brieflyExpandLauncher() {
1186
1781
  launcherSettings.expandedUntil = Date.now() + 2600;
1187
1782
  applyLauncherSettings();
@@ -1316,7 +1911,20 @@ export function getOverlayClientScript() {
1316
1911
 
1317
1912
  function shouldTrackRequestUrl(value) {
1318
1913
  const normalized = normalizeUrl(value);
1319
- return Boolean(normalized && !shouldIgnoreRequest(normalized));
1914
+ return Boolean(normalized && isLocalProjectOrigin(normalized) && !shouldIgnoreRequest(normalized));
1915
+ }
1916
+
1917
+ function isLocalProjectOrigin(url) {
1918
+ if (url.origin === window.location.origin) {
1919
+ return true;
1920
+ }
1921
+
1922
+ return (
1923
+ url.hostname === "localhost" ||
1924
+ url.hostname === "127.0.0.1" ||
1925
+ url.hostname === "[::1]" ||
1926
+ url.hostname === "::1"
1927
+ );
1320
1928
  }
1321
1929
 
1322
1930
  function matchEndpoint(method, path) {
@@ -1460,8 +2068,9 @@ export function getOverlayClientScript() {
1460
2068
  function withDefaultDependencies(dependencies) {
1461
2069
  return {
1462
2070
  loadConfig: dependencies?.loadConfig ?? loadConfig,
2071
+ readProjectData: dependencies?.readProjectData ?? readProjectData,
1463
2072
  readReportData: dependencies?.readReportData ?? readReportData,
1464
- runScanCommand: dependencies?.runScanCommand ?? runScanCommand,
2073
+ readValidationReport: dependencies?.readValidationReport ?? readValidationReport,
1465
2074
  createLocalUiServer: dependencies?.createLocalUiServer ?? createLocalUiServer,
1466
2075
  isFrontendReachable: dependencies?.isFrontendReachable ?? isFrontendReachable,
1467
2076
  startFrontendDevServer: dependencies?.startFrontendDevServer ?? startFrontendDevServer,