anlyx 0.1.3 → 0.1.5

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,26 @@ 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, 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 loadedData = await ensureProjectData({
23
+ projectDataPath,
24
+ splitProjectDataPath,
25
+ dependencies
27
26
  });
28
27
  const frontendStarted = await ensureFrontendDevServer({
29
28
  cwd,
@@ -33,7 +32,7 @@ export async function runDevCommand(options = {}) {
33
32
  const port = options.port ?? getConfiguredPort(config);
34
33
  const server = await dependencies.createLocalUiServer({
35
34
  port,
36
- reportData,
35
+ projectData: loadedData.data,
37
36
  viewerRoot: getViewerRoot(),
38
37
  frontendBaseUrl: config.frontend.baseUrl,
39
38
  mode: config.server.mode
@@ -47,14 +46,47 @@ export async function runDevCommand(options = {}) {
47
46
  return {
48
47
  url: server.url,
49
48
  port,
49
+ projectDataPath: loadedData.path,
50
50
  reportDataPath,
51
51
  mode: config.server.mode,
52
52
  frontendStarted,
53
- scanRan,
54
53
  ...(config.server.mode === "inject" ? { frontendUrl: config.frontend.baseUrl } : {}),
55
54
  ...(config.server.mode === "inject" ? { scriptTag: getOverlayScriptTag(server.url) } : {})
56
55
  };
57
56
  }
57
+ async function loadConfigOrViewerFallback(dependencies, options) {
58
+ try {
59
+ return await dependencies.loadConfig(options);
60
+ }
61
+ catch (error) {
62
+ if (!isMissingConfigError(error)) {
63
+ throw error;
64
+ }
65
+ return createViewerFallbackConfig();
66
+ }
67
+ }
68
+ function createViewerFallbackConfig() {
69
+ return {
70
+ projectName: "Anlyx Project JSON",
71
+ backend: {
72
+ type: "openapi",
73
+ openApiUrl: "about:blank"
74
+ },
75
+ frontend: {
76
+ type: "manual",
77
+ baseUrl: "http://localhost:4777",
78
+ urls: [],
79
+ viewport: { width: 1440, height: 900 },
80
+ capture: { mode: "segments", segmentHeight: 900 }
81
+ },
82
+ server: {
83
+ port: 4777,
84
+ openBrowser: true,
85
+ mode: "viewer"
86
+ },
87
+ dev: {}
88
+ };
89
+ }
58
90
  export async function closeActiveLocalUiServers() {
59
91
  const servers = Array.from(activeLocalUiServers);
60
92
  activeLocalUiServers.clear();
@@ -62,33 +94,21 @@ export async function closeActiveLocalUiServers() {
62
94
  await server.close?.();
63
95
  }));
64
96
  }
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;
97
+ async function ensureProjectData(options) {
98
+ for (const path of [options.projectDataPath, options.splitProjectDataPath]) {
99
+ try {
100
+ return {
101
+ data: await options.dependencies.readProjectData(path),
102
+ path
103
+ };
104
+ }
105
+ catch (error) {
106
+ if (!isMissingProjectDataError(error)) {
107
+ throw error;
108
+ }
75
109
  }
76
110
  }
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
- };
111
+ throw new Error('Anlyx project data not found. Create or import "anlyx.project.json" first.');
92
112
  }
93
113
  async function ensureFrontendDevServer(options) {
94
114
  const command = options.config.dev?.command;
@@ -104,8 +124,83 @@ async function ensureFrontendDevServer(options) {
104
124
  });
105
125
  return true;
106
126
  }
107
- function isMissingReportDataError(error) {
108
- return error instanceof Error && /Run "anlyx scan" first/.test(error.message);
127
+ function isMissingProjectDataError(error) {
128
+ return error instanceof Error && /Anlyx project data not found/.test(error.message);
129
+ }
130
+ function isMissingConfigError(error) {
131
+ return error instanceof Error && /Anlyx config file not found/.test(error.message);
132
+ }
133
+ export async function readProjectData(path) {
134
+ let content;
135
+ try {
136
+ content = await readFile(path, "utf8");
137
+ }
138
+ catch (error) {
139
+ if (isNodeError(error) && error.code === "ENOENT") {
140
+ throw new Error(`Anlyx project data not found: ${path}. Create anlyx.project.json first.`);
141
+ }
142
+ throw error;
143
+ }
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(content);
147
+ }
148
+ catch (error) {
149
+ throw new Error(`Failed to parse Anlyx project data JSON at ${path}: ${error instanceof Error ? error.message : "invalid JSON"}`);
150
+ }
151
+ try {
152
+ if (path.endsWith("/index.json")) {
153
+ return await readSplitProjectData(path, parsed);
154
+ }
155
+ return normalizeProjectInput(parseProjectData(parsed));
156
+ }
157
+ catch (error) {
158
+ throw new Error(`Invalid Anlyx project data at ${path}: ${error instanceof Error ? error.message : "invalid project data"}`);
159
+ }
160
+ }
161
+ async function readSplitProjectData(indexPath, index) {
162
+ const projectDir = dirname(indexPath);
163
+ const [areas, pages, features, requests, flows, architecture, evidence, measurements, dictionary] = await Promise.all([
164
+ readOptionalProjectJson(join(projectDir, "areas.json")),
165
+ readOptionalProjectJson(join(projectDir, "pages.json")),
166
+ readOptionalProjectJson(join(projectDir, "features.json")),
167
+ readOptionalProjectJson(join(projectDir, "requests.json")),
168
+ readOptionalProjectJson(join(projectDir, "flows.json")),
169
+ readOptionalProjectJson(join(projectDir, "architecture.json")),
170
+ readOptionalProjectJson(join(projectDir, "evidence.json")),
171
+ readOptionalProjectJson(join(projectDir, "measurements.json")),
172
+ readOptionalProjectJson(join(projectDir, "dictionary.json"))
173
+ ]);
174
+ return normalizeProjectInput({
175
+ index,
176
+ ...(areas === undefined ? {} : { areas }),
177
+ ...(pages === undefined ? {} : { pages }),
178
+ ...(features === undefined ? {} : { features }),
179
+ ...(requests === undefined ? {} : { requests }),
180
+ ...(flows === undefined ? {} : { flows }),
181
+ ...(architecture === undefined ? {} : { architecture }),
182
+ ...(evidence === undefined ? {} : { evidence }),
183
+ ...(measurements === undefined ? {} : { measurements }),
184
+ ...(dictionary === undefined ? {} : { dictionary })
185
+ });
186
+ }
187
+ async function readOptionalProjectJson(path) {
188
+ let content;
189
+ try {
190
+ content = await readFile(path, "utf8");
191
+ }
192
+ catch (error) {
193
+ if (isNodeError(error) && error.code === "ENOENT") {
194
+ return undefined;
195
+ }
196
+ throw error;
197
+ }
198
+ try {
199
+ return JSON.parse(content);
200
+ }
201
+ catch (error) {
202
+ throw new Error(`Failed to parse split Anlyx project JSON at ${path}: ${error instanceof Error ? error.message : "invalid JSON"}`);
203
+ }
109
204
  }
110
205
  export async function readReportData(path) {
111
206
  let content;
@@ -114,7 +209,7 @@ export async function readReportData(path) {
114
209
  }
115
210
  catch (error) {
116
211
  if (isNodeError(error) && error.code === "ENOENT") {
117
- throw new Error(`Anlyx report data not found: ${path}. Run "anlyx scan" first.`);
212
+ throw new Error(`Anlyx report data not found: ${path}. Create or import "anlyx.project.json" first.`);
118
213
  }
119
214
  throw error;
120
215
  }
@@ -194,11 +289,12 @@ export function startFrontendDevServer(options) {
194
289
  };
195
290
  }
196
291
  function createAnlyxDevPlugin(options) {
292
+ const liveEvents = options.reportData ? createLiveEventRuntime(options.reportData) : undefined;
197
293
  return {
198
294
  name: "anlyx-dev-runtime",
199
295
  configureServer(server) {
200
296
  server.middlewares.use((request, response, next) => {
201
- if (request.method === "GET" && options.mode === "viewer" && request.url === "/") {
297
+ if (isReadRequest(request.method) && options.mode === "viewer" && request.url === "/") {
202
298
  request.url = "/viewer.html";
203
299
  }
204
300
  next();
@@ -211,10 +307,54 @@ function createAnlyxDevPlugin(options) {
211
307
  response.end();
212
308
  return;
213
309
  }
310
+ if (request.method === "GET" && isProjectDataPath(requestUrl)) {
311
+ if (!options.projectData) {
312
+ sendJsonError(response, 404, new Error("Anlyx project data not loaded."));
313
+ return;
314
+ }
315
+ sendJson(response, options.projectData);
316
+ return;
317
+ }
214
318
  if (request.method === "GET" && isReportDataPath(requestUrl)) {
319
+ if (!options.reportData) {
320
+ sendJsonError(response, 404, new Error("Legacy Anlyx report data not loaded."));
321
+ return;
322
+ }
215
323
  sendJson(response, options.reportData);
216
324
  return;
217
325
  }
326
+ if (request.method === "GET" && requestUrl === "/_anlyx/events/stream") {
327
+ if (!liveEvents) {
328
+ sendJsonError(response, 404, new Error("Legacy live events are not available for project data."));
329
+ return;
330
+ }
331
+ liveEvents.openStream(request, response);
332
+ return;
333
+ }
334
+ if (request.method === "POST" && requestUrl === "/_anlyx/events/browser-request") {
335
+ if (!liveEvents) {
336
+ sendJsonError(response, 404, new Error("Legacy live events are not available for project data."));
337
+ return;
338
+ }
339
+ await liveEvents.recordBrowserRequest(request, response);
340
+ return;
341
+ }
342
+ if (request.method === "POST" && requestUrl === "/_anlyx/events/page-context") {
343
+ if (!liveEvents) {
344
+ sendJsonError(response, 404, new Error("Legacy live events are not available for project data."));
345
+ return;
346
+ }
347
+ await liveEvents.recordPageContext(request, response);
348
+ return;
349
+ }
350
+ if (request.method === "POST" && requestUrl === "/_anlyx/events/backend-spans") {
351
+ if (!liveEvents) {
352
+ sendJsonError(response, 404, new Error("Legacy live events are not available for project data."));
353
+ return;
354
+ }
355
+ await liveEvents.recordBackendSpans(request, response);
356
+ return;
357
+ }
218
358
  if (request.method === "GET" && requestUrl === "/_anlyx/overlay.js") {
219
359
  response.statusCode = 200;
220
360
  setCorsHeaders(response);
@@ -236,7 +376,7 @@ function createAnlyxDevPlugin(options) {
236
376
  response.end(getInjectModeHtml(options.frontendBaseUrl, getOverlayScriptTag(getServerUrl(options.port))));
237
377
  return;
238
378
  }
239
- if (request.method === "GET" && isStandaloneViewerPath(requestUrl)) {
379
+ if (isReadRequest(request.method) && isStandaloneViewerPath(requestUrl)) {
240
380
  request.url = "/viewer.html";
241
381
  next();
242
382
  return;
@@ -256,12 +396,137 @@ function createAnlyxDevPlugin(options) {
256
396
  }
257
397
  };
258
398
  }
399
+ export function createLiveEventRuntime(reportData) {
400
+ const streams = new Set();
401
+ const recordsByRequestId = new Map();
402
+ const pendingSpansByRequestId = new Map();
403
+ let latestPageContext;
404
+ let recentRecords = [];
405
+ const rememberRecord = (record) => {
406
+ recordsByRequestId.set(record.requestId, record);
407
+ recentRecords = [record, ...recentRecords.filter((item) => item.id !== record.id)].slice(0, 90);
408
+ };
409
+ const broadcastRecord = (record) => {
410
+ rememberRecord(record);
411
+ for (const stream of streams) {
412
+ writeFlowSseEvent(stream, record);
413
+ }
414
+ };
415
+ const broadcastPageContext = (event) => {
416
+ latestPageContext = event;
417
+ for (const stream of streams) {
418
+ writePageContextSseEvent(stream, event);
419
+ }
420
+ };
421
+ return {
422
+ openStream(request, response) {
423
+ response.statusCode = 200;
424
+ setCorsHeaders(response);
425
+ response.setHeader("content-type", "text/event-stream; charset=utf-8");
426
+ response.setHeader("cache-control", "no-cache, no-transform");
427
+ response.setHeader("connection", "keep-alive");
428
+ response.write(": Anlyx event stream ready\n\n");
429
+ streams.add(response);
430
+ if (latestPageContext) {
431
+ writePageContextSseEvent(response, latestPageContext);
432
+ }
433
+ for (const record of [...recentRecords].reverse()) {
434
+ writeFlowSseEvent(response, record);
435
+ }
436
+ const heartbeat = setInterval(() => {
437
+ response.write(": heartbeat\n\n");
438
+ }, 30000);
439
+ request.on("close", () => {
440
+ clearInterval(heartbeat);
441
+ streams.delete(response);
442
+ response.end();
443
+ });
444
+ },
445
+ async recordBrowserRequest(request, response) {
446
+ let payload;
447
+ try {
448
+ payload = await readJsonRequestBody(request);
449
+ }
450
+ catch (error) {
451
+ sendJsonError(response, 400, error);
452
+ return;
453
+ }
454
+ if (!isBrowserRequestEvent(payload)) {
455
+ sendJsonError(response, 400, new Error("Invalid Anlyx browser request event payload."));
456
+ return;
457
+ }
458
+ const event = withRequestPageContext(payload, request, latestPageContext);
459
+ if (event.pageUrl) {
460
+ latestPageContext = {
461
+ type: "page_context",
462
+ pageUrl: event.pageUrl,
463
+ contextId: event.contextId ?? pageContextId(event.pageUrl),
464
+ observedAt: event.observedAt
465
+ };
466
+ }
467
+ let record = buildFlowRecordFromBrowserEvent(event, reportData);
468
+ const pendingSpans = pendingSpansByRequestId.get(event.id);
469
+ if (pendingSpans) {
470
+ pendingSpansByRequestId.delete(event.id);
471
+ record = mergeBackendSpansIntoFlowRecord(record, pendingSpans);
472
+ }
473
+ broadcastRecord(record);
474
+ sendAccepted(response, { accepted: true, id: record.id });
475
+ },
476
+ async recordPageContext(request, response) {
477
+ let payload;
478
+ try {
479
+ payload = await readJsonRequestBody(request);
480
+ }
481
+ catch (error) {
482
+ sendJsonError(response, 400, error);
483
+ return;
484
+ }
485
+ if (!isPageContextEvent(payload)) {
486
+ sendJsonError(response, 400, new Error("Invalid Anlyx page context event payload."));
487
+ return;
488
+ }
489
+ broadcastPageContext(payload);
490
+ sendAccepted(response, { accepted: true, contextId: payload.contextId });
491
+ },
492
+ async recordBackendSpans(request, response) {
493
+ let payload;
494
+ try {
495
+ payload = await readJsonRequestBody(request);
496
+ }
497
+ catch (error) {
498
+ sendJsonError(response, 400, error);
499
+ return;
500
+ }
501
+ if (!isBackendSpanEvent(payload)) {
502
+ sendJsonError(response, 400, new Error("Invalid Anlyx backend span event payload."));
503
+ return;
504
+ }
505
+ const existingRecord = recordsByRequestId.get(payload.requestId);
506
+ if (!existingRecord) {
507
+ const existingSpans = pendingSpansByRequestId.get(payload.requestId) ?? [];
508
+ pendingSpansByRequestId.set(payload.requestId, [...existingSpans, ...payload.spans]);
509
+ sendAccepted(response, { accepted: true, pending: true, requestId: payload.requestId });
510
+ return;
511
+ }
512
+ const record = mergeBackendSpansIntoFlowRecord(existingRecord, payload.spans);
513
+ broadcastRecord(record);
514
+ sendAccepted(response, { accepted: true, id: record.id });
515
+ }
516
+ };
517
+ }
259
518
  function isReportDataPath(path) {
260
519
  return path === "/_anlyx/report-data" || path === "/api/report-data";
261
520
  }
521
+ function isProjectDataPath(path) {
522
+ return path === "/_anlyx/project-data" || path === "/api/project-data";
523
+ }
262
524
  function isStandaloneViewerPath(path) {
263
525
  return path === "/_anlyx/viewer" || path === "/_anlyx/viewer.html";
264
526
  }
527
+ function isReadRequest(method) {
528
+ return method === "GET" || method === "HEAD";
529
+ }
265
530
  function isAnlyxPath(path) {
266
531
  return path.startsWith("/_anlyx/");
267
532
  }
@@ -271,6 +536,130 @@ function sendJson(response, value) {
271
536
  response.setHeader("content-type", "application/json; charset=utf-8");
272
537
  response.end(JSON.stringify(value));
273
538
  }
539
+ function sendAccepted(response, value) {
540
+ response.statusCode = 202;
541
+ setCorsHeaders(response);
542
+ response.setHeader("content-type", "application/json; charset=utf-8");
543
+ response.end(JSON.stringify(value));
544
+ }
545
+ function sendJsonError(response, statusCode, error) {
546
+ response.statusCode = statusCode;
547
+ setCorsHeaders(response);
548
+ response.setHeader("content-type", "application/json; charset=utf-8");
549
+ response.end(JSON.stringify({
550
+ error: error instanceof Error ? error.message : "Invalid Anlyx runtime event."
551
+ }));
552
+ }
553
+ async function readJsonRequestBody(request) {
554
+ const body = await readRequestBody(request);
555
+ if (!body || body.byteLength === 0) {
556
+ throw new Error("Missing JSON request body.");
557
+ }
558
+ try {
559
+ return JSON.parse(body.toString("utf8"));
560
+ }
561
+ catch (error) {
562
+ throw new Error(`Failed to parse Anlyx runtime event JSON: ${error instanceof Error ? error.message : "invalid JSON"}`);
563
+ }
564
+ }
565
+ function writeFlowSseEvent(response, record) {
566
+ response.write("event: flow\n");
567
+ response.write(`data: ${JSON.stringify(record)}\n\n`);
568
+ }
569
+ function writePageContextSseEvent(response, event) {
570
+ response.write("event: page-context\n");
571
+ response.write(`data: ${JSON.stringify(event)}\n\n`);
572
+ }
573
+ function withRequestPageContext(event, request, latestPageContext) {
574
+ const pageUrl = event.pageUrl ?? pageUrlFromReferer(request.headers.referer) ?? latestPageContext?.pageUrl;
575
+ if (!pageUrl) {
576
+ return event;
577
+ }
578
+ return {
579
+ ...event,
580
+ pageUrl,
581
+ contextId: event.contextId ?? latestPageContext?.contextId ?? pageContextId(pageUrl)
582
+ };
583
+ }
584
+ function pageUrlFromReferer(referer) {
585
+ const value = Array.isArray(referer) ? referer[0] : referer;
586
+ if (!value) {
587
+ return undefined;
588
+ }
589
+ try {
590
+ const parsed = new URL(value);
591
+ if (parsed.pathname.startsWith("/_anlyx/")) {
592
+ return undefined;
593
+ }
594
+ return `${parsed.origin}${parsed.pathname}${parsed.search}`;
595
+ }
596
+ catch {
597
+ return undefined;
598
+ }
599
+ }
600
+ function pageContextId(pageUrl) {
601
+ try {
602
+ const parsed = new URL(pageUrl);
603
+ return `page:${parsed.pathname}${parsed.search}`;
604
+ }
605
+ catch {
606
+ return `page:${pageUrl || "/"}`;
607
+ }
608
+ }
609
+ function isPageContextEvent(value) {
610
+ if (!value || typeof value !== "object") {
611
+ return false;
612
+ }
613
+ const event = value;
614
+ return (event.type === "page_context" &&
615
+ typeof event.pageUrl === "string" &&
616
+ typeof event.contextId === "string" &&
617
+ typeof event.observedAt === "string");
618
+ }
619
+ function isBrowserRequestEvent(value) {
620
+ if (!value || typeof value !== "object") {
621
+ return false;
622
+ }
623
+ const event = value;
624
+ return (event.type === "request" &&
625
+ typeof event.id === "string" &&
626
+ typeof event.method === "string" &&
627
+ typeof event.url === "string" &&
628
+ typeof event.observedAt === "string" &&
629
+ (event.path === undefined || typeof event.path === "string") &&
630
+ (event.pageUrl === undefined || typeof event.pageUrl === "string") &&
631
+ (event.status === undefined || typeof event.status === "number") &&
632
+ (event.durationMs === undefined || typeof event.durationMs === "number") &&
633
+ (event.contextId === undefined || typeof event.contextId === "string") &&
634
+ (event.priority === undefined ||
635
+ event.priority === "primary" ||
636
+ event.priority === "background") &&
637
+ (event.runtimeSource === undefined ||
638
+ event.runtimeSource === "browser" ||
639
+ event.runtimeSource === "server" ||
640
+ event.runtimeSource === "unknown"));
641
+ }
642
+ function isBackendSpanEvent(value) {
643
+ if (!value || typeof value !== "object") {
644
+ return false;
645
+ }
646
+ const event = value;
647
+ return (event.type === "backend_spans" &&
648
+ typeof event.requestId === "string" &&
649
+ Array.isArray(event.spans) &&
650
+ event.spans.every(isBackendObservedSpan));
651
+ }
652
+ function isBackendObservedSpan(value) {
653
+ if (!value || typeof value !== "object") {
654
+ return false;
655
+ }
656
+ const span = value;
657
+ return (typeof span.id === "string" &&
658
+ typeof span.type === "string" &&
659
+ typeof span.label === "string" &&
660
+ typeof span.startOffsetMs === "number" &&
661
+ typeof span.durationMs === "number");
662
+ }
274
663
  async function sendRuntimeAsset(response, path, contentType) {
275
664
  try {
276
665
  const content = await readFile(path);
@@ -288,8 +677,8 @@ async function sendRuntimeAsset(response, path, contentType) {
288
677
  }
289
678
  function setCorsHeaders(response) {
290
679
  response.setHeader("access-control-allow-origin", "*");
291
- response.setHeader("access-control-allow-methods", "GET, OPTIONS");
292
- response.setHeader("access-control-allow-headers", "content-type");
680
+ response.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
681
+ response.setHeader("access-control-allow-headers", "content-type, x-anlyx-request-id");
293
682
  }
294
683
  function getServerUrl(port) {
295
684
  return `http://localhost:${port}`;
@@ -660,6 +1049,7 @@ export function getOverlayClientScript() {
660
1049
  installUserActionTracker(root);
661
1050
  installFetchInterceptor();
662
1051
  installXhrInterceptor();
1052
+ installPageContextReporter();
663
1053
  loadReport();
664
1054
  }
665
1055
 
@@ -952,22 +1342,49 @@ export function getOverlayClientScript() {
952
1342
  window.fetch = async function anlyxFetch(input, init) {
953
1343
  const method = ((init && init.method) || (input && input.method) || "GET").toUpperCase();
954
1344
  const url = typeof input === "string" ? input : input && input.url;
1345
+ const shouldTrack = shouldTrackRequestUrl(url);
1346
+ const requestId = shouldTrack ? createRequestId() : null;
1347
+ const args = requestId ? withAnlyxFetchRequestId(input, init, requestId) : arguments;
955
1348
  const startedAt = performance.now();
956
1349
  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 });
1350
+ const response = await originalFetch.apply(this, args);
1351
+ if (shouldTrack) {
1352
+ scheduleApiEventRecord({ id: requestId, method, url, status: response.status, durationMs: performance.now() - startedAt, startedAt });
960
1353
  }
961
1354
  return response;
962
1355
  } catch (error) {
963
- if (shouldTrackRequestUrl(url)) {
964
- scheduleApiEventRecord({ method, url, status: "failed", durationMs: performance.now() - startedAt, startedAt });
1356
+ if (shouldTrack) {
1357
+ scheduleApiEventRecord({ id: requestId, method, url, status: "failed", durationMs: performance.now() - startedAt, startedAt });
965
1358
  }
966
1359
  throw error;
967
1360
  }
968
1361
  };
969
1362
  }
970
1363
 
1364
+ function createRequestId() {
1365
+ return "anlyx-" + Date.now() + "-" + Math.random().toString(36).slice(2);
1366
+ }
1367
+
1368
+ function withAnlyxFetchRequestId(input, init, requestId) {
1369
+ const nextInit = Object.assign({}, init || {});
1370
+ nextInit.headers = appendHeader(nextInit.headers || (input && input.headers), "X-Anlyx-Request-Id", requestId);
1371
+ return [input, nextInit];
1372
+ }
1373
+
1374
+ function appendHeader(headers, name, value) {
1375
+ try {
1376
+ if (typeof Headers !== "undefined") {
1377
+ const nextHeaders = new Headers(headers || {});
1378
+ nextHeaders.set(name, value);
1379
+ return nextHeaders;
1380
+ }
1381
+ } catch {
1382
+ // Fall through to a plain object header bag when Headers cannot clone input.
1383
+ }
1384
+
1385
+ return Object.assign({}, headers || {}, { [name]: value });
1386
+ }
1387
+
971
1388
  function installUserActionTracker(root) {
972
1389
  document.addEventListener("pointerdown", (event) => captureUserAction(event, root), true);
973
1390
  document.addEventListener("click", (event) => captureUserAction(event, root), true);
@@ -1093,7 +1510,13 @@ export function getOverlayClientScript() {
1093
1510
  const originalSend = XMLHttpRequest.prototype.send;
1094
1511
 
1095
1512
  XMLHttpRequest.prototype.open = function anlyxOpen(method, url) {
1096
- this.__anlyxRequest = { method: String(method || "GET").toUpperCase(), url: String(url || ""), startedAt: 0 };
1513
+ const requestUrl = String(url || "");
1514
+ this.__anlyxRequest = {
1515
+ id: shouldTrackRequestUrl(requestUrl) ? createRequestId() : null,
1516
+ method: String(method || "GET").toUpperCase(),
1517
+ url: requestUrl,
1518
+ startedAt: 0
1519
+ };
1097
1520
  return originalOpen.apply(this, arguments);
1098
1521
  };
1099
1522
 
@@ -1101,9 +1524,17 @@ export function getOverlayClientScript() {
1101
1524
  const request = this.__anlyxRequest;
1102
1525
  if (request) {
1103
1526
  request.startedAt = performance.now();
1527
+ if (request.id && this.setRequestHeader) {
1528
+ try {
1529
+ this.setRequestHeader("X-Anlyx-Request-Id", request.id);
1530
+ } catch {
1531
+ // Ignore header injection failures; the browser event can still be observed.
1532
+ }
1533
+ }
1104
1534
  this.addEventListener("loadend", () => {
1105
1535
  if (shouldTrackRequestUrl(request.url)) {
1106
1536
  scheduleApiEventRecord({
1537
+ id: request.id,
1107
1538
  method: request.method,
1108
1539
  url: request.url,
1109
1540
  status: this.status || "unknown",
@@ -1131,14 +1562,16 @@ export function getOverlayClientScript() {
1131
1562
  const passive = isPassiveRequest(event.method, normalized.pathname);
1132
1563
  const triggeredBy = passive ? null : findActionForRequest(event.startedAt);
1133
1564
  const item = {
1134
- id: String(Date.now()) + "-" + Math.random().toString(36).slice(2),
1565
+ id: event.id || createRequestId(),
1135
1566
  method: event.method,
1136
1567
  path: normalized.pathname,
1137
1568
  status: event.status,
1138
1569
  durationMs: Math.round(event.durationMs),
1139
1570
  count: 1,
1571
+ contextId: getCurrentContextId(),
1140
1572
  lastSeenAt: Date.now(),
1141
1573
  triggeredBy,
1574
+ priority: passive ? "background" : "primary",
1142
1575
  source: triggeredBy ? "action" : classifyApiEventSource(normalized.pathname),
1143
1576
  matchedEndpoint: matched.endpoint,
1144
1577
  matchedFlow: matched.flow,
@@ -1152,8 +1585,10 @@ export function getOverlayClientScript() {
1152
1585
  status: item.status,
1153
1586
  durationMs: item.durationMs,
1154
1587
  count: (existing.count || 1) + 1,
1588
+ contextId: item.contextId,
1155
1589
  lastSeenAt: item.lastSeenAt,
1156
1590
  triggeredBy: item.triggeredBy || existing.triggeredBy,
1591
+ priority: item.priority,
1157
1592
  source: item.triggeredBy ? "action" : item.source,
1158
1593
  matchedEndpoint: item.matchedEndpoint,
1159
1594
  matchedFlow: item.matchedFlow,
@@ -1166,6 +1601,7 @@ export function getOverlayClientScript() {
1166
1601
  state.open = true;
1167
1602
  }
1168
1603
  render();
1604
+ sendBrowserRequestEvent(updated);
1169
1605
  return;
1170
1606
  }
1171
1607
 
@@ -1176,12 +1612,124 @@ export function getOverlayClientScript() {
1176
1612
  state.open = true;
1177
1613
  }
1178
1614
  render();
1615
+ sendBrowserRequestEvent(item);
1616
+ }
1617
+
1618
+ function sendBrowserRequestEvent(item) {
1619
+ if (!window.fetch || !item || item.source === "anlyx") {
1620
+ return;
1621
+ }
1622
+
1623
+ const payload = {
1624
+ id: item.id,
1625
+ type: "request",
1626
+ method: item.method,
1627
+ url: item.path,
1628
+ path: item.path,
1629
+ pageUrl: getCurrentPageUrl(),
1630
+ status: typeof item.status === "number" ? item.status : undefined,
1631
+ durationMs: typeof item.durationMs === "number" ? item.durationMs : undefined,
1632
+ observedAt: new Date(item.lastSeenAt || Date.now()).toISOString(),
1633
+ contextId: item.contextId,
1634
+ priority: item.priority === "background" ? "background" : "primary",
1635
+ runtimeSource: "browser",
1636
+ action: item.triggeredBy ? {
1637
+ label: item.triggeredBy.label,
1638
+ selector: item.triggeredBy.selector,
1639
+ observedAt: new Date(item.triggeredBy.capturedAt || item.lastSeenAt || Date.now()).toISOString()
1640
+ } : undefined
1641
+ };
1642
+
1643
+ try {
1644
+ window.fetch(runtimeBaseUrl + "/_anlyx/events/browser-request", {
1645
+ method: "POST",
1646
+ headers: { "content-type": "application/json" },
1647
+ body: JSON.stringify(payload),
1648
+ keepalive: true
1649
+ }).catch(() => undefined);
1650
+ } catch {
1651
+ // Runtime ingestion is best-effort and must never break the user's app.
1652
+ }
1179
1653
  }
1180
1654
 
1181
1655
  function shouldAutoFocusEvent(item) {
1182
1656
  return Boolean(item && item.triggeredBy);
1183
1657
  }
1184
1658
 
1659
+ function getCurrentContextId() {
1660
+ const path = window.location && window.location.pathname ? window.location.pathname : "/";
1661
+ const search = window.location && window.location.search ? window.location.search : "";
1662
+ return "page:" + path + search;
1663
+ }
1664
+
1665
+ function getCurrentPageUrl() {
1666
+ if (!window.location) {
1667
+ return "/";
1668
+ }
1669
+
1670
+ return window.location.href.split("#")[0] || "/";
1671
+ }
1672
+
1673
+ function installPageContextReporter() {
1674
+ let lastPageUrl = "";
1675
+ let timer = null;
1676
+
1677
+ const schedulePageContext = () => {
1678
+ if (timer !== null) {
1679
+ window.clearTimeout(timer);
1680
+ }
1681
+ timer = window.setTimeout(() => {
1682
+ timer = null;
1683
+ sendPageContextEvent();
1684
+ }, 0);
1685
+ };
1686
+
1687
+ const wrapHistoryMethod = (methodName) => {
1688
+ const original = window.history && window.history[methodName];
1689
+ if (!original || original.__anlyxWrapped) {
1690
+ return;
1691
+ }
1692
+
1693
+ const wrapped = function anlyxHistoryWrapper() {
1694
+ const result = original.apply(this, arguments);
1695
+ schedulePageContext();
1696
+ return result;
1697
+ };
1698
+ wrapped.__anlyxWrapped = true;
1699
+ window.history[methodName] = wrapped;
1700
+ };
1701
+
1702
+ const sendPageContextEvent = () => {
1703
+ const pageUrl = getCurrentPageUrl();
1704
+ if (!pageUrl || pageUrl === lastPageUrl) {
1705
+ return;
1706
+ }
1707
+ lastPageUrl = pageUrl;
1708
+
1709
+ try {
1710
+ window.fetch(runtimeBaseUrl + "/_anlyx/events/page-context", {
1711
+ method: "POST",
1712
+ headers: { "content-type": "application/json" },
1713
+ body: JSON.stringify({
1714
+ type: "page_context",
1715
+ pageUrl,
1716
+ contextId: getCurrentContextId(),
1717
+ observedAt: new Date().toISOString()
1718
+ }),
1719
+ keepalive: true
1720
+ }).catch(() => undefined);
1721
+ } catch {
1722
+ // Page context reporting is best-effort and must never affect the app.
1723
+ }
1724
+ };
1725
+
1726
+ wrapHistoryMethod("pushState");
1727
+ wrapHistoryMethod("replaceState");
1728
+ window.addEventListener("popstate", schedulePageContext);
1729
+ window.addEventListener("hashchange", schedulePageContext);
1730
+ sendPageContextEvent();
1731
+ }
1732
+
1185
1733
  function brieflyExpandLauncher() {
1186
1734
  launcherSettings.expandedUntil = Date.now() + 2600;
1187
1735
  applyLauncherSettings();
@@ -1316,7 +1864,20 @@ export function getOverlayClientScript() {
1316
1864
 
1317
1865
  function shouldTrackRequestUrl(value) {
1318
1866
  const normalized = normalizeUrl(value);
1319
- return Boolean(normalized && !shouldIgnoreRequest(normalized));
1867
+ return Boolean(normalized && isLocalProjectOrigin(normalized) && !shouldIgnoreRequest(normalized));
1868
+ }
1869
+
1870
+ function isLocalProjectOrigin(url) {
1871
+ if (url.origin === window.location.origin) {
1872
+ return true;
1873
+ }
1874
+
1875
+ return (
1876
+ url.hostname === "localhost" ||
1877
+ url.hostname === "127.0.0.1" ||
1878
+ url.hostname === "[::1]" ||
1879
+ url.hostname === "::1"
1880
+ );
1320
1881
  }
1321
1882
 
1322
1883
  function matchEndpoint(method, path) {
@@ -1460,8 +2021,8 @@ export function getOverlayClientScript() {
1460
2021
  function withDefaultDependencies(dependencies) {
1461
2022
  return {
1462
2023
  loadConfig: dependencies?.loadConfig ?? loadConfig,
2024
+ readProjectData: dependencies?.readProjectData ?? readProjectData,
1463
2025
  readReportData: dependencies?.readReportData ?? readReportData,
1464
- runScanCommand: dependencies?.runScanCommand ?? runScanCommand,
1465
2026
  createLocalUiServer: dependencies?.createLocalUiServer ?? createLocalUiServer,
1466
2027
  isFrontendReachable: dependencies?.isFrontendReachable ?? isFrontendReachable,
1467
2028
  startFrontendDevServer: dependencies?.startFrontendDevServer ?? startFrontendDevServer,