anlyx 0.1.2 → 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.
- package/README.md +53 -5
- package/dist/dev-command.d.ts +35 -2
- package/dist/dev-command.d.ts.map +1 -1
- package/dist/dev-command.js +1930 -18
- package/dist/dev-command.js.map +1 -1
- package/dist/flow-commands.d.ts +41 -0
- package/dist/flow-commands.d.ts.map +1 -0
- package/dist/flow-commands.js +180 -0
- package/dist/flow-commands.js.map +1 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +159 -64
- package/dist/index.js.map +1 -1
- package/dist/init-command.d.ts.map +1 -1
- package/dist/init-command.js +7 -28
- package/dist/init-command.js.map +1 -1
- package/dist/next.d.ts +8 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +16 -0
- package/dist/next.js.map +1 -0
- package/package.json +20 -9
- package/dist/scan-command.d.ts +0 -45
- package/dist/scan-command.d.ts.map +0 -1
- package/dist/scan-command.js +0 -153
- package/dist/scan-command.js.map +0 -1
package/dist/dev-command.js
CHANGED
|
@@ -3,35 +3,205 @@ 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
9
|
const require = createRequire(import.meta.url);
|
|
10
|
+
const activeLocalUiServers = new Set();
|
|
10
11
|
export async function runDevCommand(options = {}) {
|
|
11
12
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
12
13
|
const dependencies = withDefaultDependencies(options.dependencies);
|
|
13
|
-
const config = await dependencies
|
|
14
|
+
const config = await loadConfigOrViewerFallback(dependencies, {
|
|
14
15
|
cwd,
|
|
15
16
|
...(options.configPath ? { configPath: options.configPath } : {})
|
|
16
17
|
});
|
|
17
|
-
const
|
|
18
|
-
const
|
|
18
|
+
const outputDir = resolve(cwd, options.outputDir ?? ".anlyx");
|
|
19
|
+
const projectDataPath = join(cwd, "anlyx.project.json");
|
|
20
|
+
const splitProjectDataPath = join(outputDir, "project", "index.json");
|
|
21
|
+
const reportDataPath = join(outputDir, "report-data.json");
|
|
22
|
+
const loadedData = await ensureProjectData({
|
|
23
|
+
projectDataPath,
|
|
24
|
+
splitProjectDataPath,
|
|
25
|
+
dependencies
|
|
26
|
+
});
|
|
27
|
+
const frontendStarted = await ensureFrontendDevServer({
|
|
28
|
+
cwd,
|
|
29
|
+
config,
|
|
30
|
+
dependencies
|
|
31
|
+
});
|
|
19
32
|
const port = options.port ?? getConfiguredPort(config);
|
|
20
33
|
const server = await dependencies.createLocalUiServer({
|
|
21
34
|
port,
|
|
22
|
-
|
|
23
|
-
viewerRoot: getViewerRoot()
|
|
35
|
+
projectData: loadedData.data,
|
|
36
|
+
viewerRoot: getViewerRoot(),
|
|
37
|
+
frontendBaseUrl: config.frontend.baseUrl,
|
|
38
|
+
mode: config.server.mode
|
|
24
39
|
});
|
|
40
|
+
activeLocalUiServers.add(server);
|
|
25
41
|
const shouldOpenBrowser = options.open ?? config.server.openBrowser;
|
|
42
|
+
const browserUrl = config.server.mode === "inject" ? config.frontend.baseUrl : server.url;
|
|
26
43
|
if (shouldOpenBrowser) {
|
|
27
|
-
await dependencies.openBrowser(
|
|
44
|
+
await dependencies.openBrowser(browserUrl);
|
|
28
45
|
}
|
|
29
46
|
return {
|
|
30
47
|
url: server.url,
|
|
31
48
|
port,
|
|
32
|
-
|
|
49
|
+
projectDataPath: loadedData.path,
|
|
50
|
+
reportDataPath,
|
|
51
|
+
mode: config.server.mode,
|
|
52
|
+
frontendStarted,
|
|
53
|
+
...(config.server.mode === "inject" ? { frontendUrl: config.frontend.baseUrl } : {}),
|
|
54
|
+
...(config.server.mode === "inject" ? { scriptTag: getOverlayScriptTag(server.url) } : {})
|
|
33
55
|
};
|
|
34
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
|
+
}
|
|
90
|
+
export async function closeActiveLocalUiServers() {
|
|
91
|
+
const servers = Array.from(activeLocalUiServers);
|
|
92
|
+
activeLocalUiServers.clear();
|
|
93
|
+
await Promise.all(servers.map(async (server) => {
|
|
94
|
+
await server.close?.();
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
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
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw new Error('Anlyx project data not found. Create or import "anlyx.project.json" first.');
|
|
112
|
+
}
|
|
113
|
+
async function ensureFrontendDevServer(options) {
|
|
114
|
+
const command = options.config.dev?.command;
|
|
115
|
+
if (!command) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (await options.dependencies.isFrontendReachable(options.config.frontend.baseUrl)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
options.dependencies.startFrontendDevServer({
|
|
122
|
+
command,
|
|
123
|
+
cwd: options.cwd
|
|
124
|
+
});
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
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
|
+
}
|
|
204
|
+
}
|
|
35
205
|
export async function readReportData(path) {
|
|
36
206
|
let content;
|
|
37
207
|
try {
|
|
@@ -39,7 +209,7 @@ export async function readReportData(path) {
|
|
|
39
209
|
}
|
|
40
210
|
catch (error) {
|
|
41
211
|
if (isNodeError(error) && error.code === "ENOENT") {
|
|
42
|
-
throw new Error(`Anlyx report data not found: ${path}.
|
|
212
|
+
throw new Error(`Anlyx report data not found: ${path}. Create or import "anlyx.project.json" first.`);
|
|
43
213
|
}
|
|
44
214
|
throw error;
|
|
45
215
|
}
|
|
@@ -71,7 +241,7 @@ export async function createLocalUiServer(options) {
|
|
|
71
241
|
port: options.port,
|
|
72
242
|
strictPort: true
|
|
73
243
|
},
|
|
74
|
-
plugins: [
|
|
244
|
+
plugins: [createAnlyxDevPlugin(options)]
|
|
75
245
|
});
|
|
76
246
|
await viteServer.listen();
|
|
77
247
|
return {
|
|
@@ -87,33 +257,1775 @@ export async function openBrowser(url) {
|
|
|
87
257
|
});
|
|
88
258
|
child.unref();
|
|
89
259
|
}
|
|
90
|
-
function
|
|
260
|
+
export async function isFrontendReachable(url) {
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
const timeout = setTimeout(() => controller.abort(), 1200);
|
|
263
|
+
try {
|
|
264
|
+
const response = await fetch(url, {
|
|
265
|
+
method: "GET",
|
|
266
|
+
signal: controller.signal
|
|
267
|
+
});
|
|
268
|
+
return response.status < 500;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
clearTimeout(timeout);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export function startFrontendDevServer(options) {
|
|
278
|
+
const child = spawn(options.command, {
|
|
279
|
+
cwd: options.cwd,
|
|
280
|
+
shell: true,
|
|
281
|
+
stdio: "inherit"
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
stop: () => {
|
|
285
|
+
if (!child.killed) {
|
|
286
|
+
child.kill();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function createAnlyxDevPlugin(options) {
|
|
292
|
+
const liveEvents = options.reportData ? createLiveEventRuntime(options.reportData) : undefined;
|
|
91
293
|
return {
|
|
92
|
-
name: "anlyx-
|
|
294
|
+
name: "anlyx-dev-runtime",
|
|
93
295
|
configureServer(server) {
|
|
94
296
|
server.middlewares.use((request, response, next) => {
|
|
95
|
-
if (request.method === "
|
|
297
|
+
if (isReadRequest(request.method) && options.mode === "viewer" && request.url === "/") {
|
|
96
298
|
request.url = "/viewer.html";
|
|
97
299
|
}
|
|
98
300
|
next();
|
|
99
301
|
});
|
|
100
|
-
server.middlewares.use(
|
|
101
|
-
|
|
302
|
+
server.middlewares.use(async (request, response, next) => {
|
|
303
|
+
const requestUrl = request.url ?? "/";
|
|
304
|
+
if (request.method === "OPTIONS" && isAnlyxPath(requestUrl)) {
|
|
305
|
+
response.statusCode = 204;
|
|
306
|
+
setCorsHeaders(response);
|
|
307
|
+
response.end();
|
|
308
|
+
return;
|
|
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
|
+
}
|
|
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
|
+
}
|
|
323
|
+
sendJson(response, options.reportData);
|
|
324
|
+
return;
|
|
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
|
+
}
|
|
358
|
+
if (request.method === "GET" && requestUrl === "/_anlyx/overlay.js") {
|
|
359
|
+
response.statusCode = 200;
|
|
360
|
+
setCorsHeaders(response);
|
|
361
|
+
response.setHeader("content-type", "application/javascript; charset=utf-8");
|
|
362
|
+
response.end(getOverlayClientScript());
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (request.method === "GET" && requestUrl === "/_anlyx/overlay-ui.js") {
|
|
366
|
+
await sendRuntimeAsset(response, join(options.viewerRoot, "../overlay/overlay-ui.js"), "application/javascript; charset=utf-8");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (request.method === "GET" && requestUrl === "/_anlyx/overlay-ui.css") {
|
|
370
|
+
await sendRuntimeAsset(response, join(options.viewerRoot, "../overlay/overlay-ui.css"), "text/css; charset=utf-8");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (request.method === "GET" && options.mode === "inject" && requestUrl === "/") {
|
|
374
|
+
response.statusCode = 200;
|
|
375
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
376
|
+
response.end(getInjectModeHtml(options.frontendBaseUrl, getOverlayScriptTag(getServerUrl(options.port))));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (isReadRequest(request.method) && isStandaloneViewerPath(requestUrl)) {
|
|
380
|
+
request.url = "/viewer.html";
|
|
102
381
|
next();
|
|
103
382
|
return;
|
|
104
383
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
384
|
+
if (options.mode === "viewer" || options.mode === "inject") {
|
|
385
|
+
next();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (isAnlyxPath(requestUrl)) {
|
|
389
|
+
response.statusCode = 404;
|
|
390
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
391
|
+
response.end("Anlyx runtime asset not found.");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
await proxyToFrontend(request, response, options.frontendBaseUrl);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
};
|
|
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();
|
|
108
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 });
|
|
109
515
|
}
|
|
110
516
|
};
|
|
111
517
|
}
|
|
518
|
+
function isReportDataPath(path) {
|
|
519
|
+
return path === "/_anlyx/report-data" || path === "/api/report-data";
|
|
520
|
+
}
|
|
521
|
+
function isProjectDataPath(path) {
|
|
522
|
+
return path === "/_anlyx/project-data" || path === "/api/project-data";
|
|
523
|
+
}
|
|
524
|
+
function isStandaloneViewerPath(path) {
|
|
525
|
+
return path === "/_anlyx/viewer" || path === "/_anlyx/viewer.html";
|
|
526
|
+
}
|
|
527
|
+
function isReadRequest(method) {
|
|
528
|
+
return method === "GET" || method === "HEAD";
|
|
529
|
+
}
|
|
530
|
+
function isAnlyxPath(path) {
|
|
531
|
+
return path.startsWith("/_anlyx/");
|
|
532
|
+
}
|
|
533
|
+
function sendJson(response, value) {
|
|
534
|
+
response.statusCode = 200;
|
|
535
|
+
setCorsHeaders(response);
|
|
536
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
537
|
+
response.end(JSON.stringify(value));
|
|
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
|
+
}
|
|
663
|
+
async function sendRuntimeAsset(response, path, contentType) {
|
|
664
|
+
try {
|
|
665
|
+
const content = await readFile(path);
|
|
666
|
+
response.statusCode = 200;
|
|
667
|
+
setCorsHeaders(response);
|
|
668
|
+
response.setHeader("content-type", contentType);
|
|
669
|
+
response.end(content);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
response.statusCode = 404;
|
|
673
|
+
setCorsHeaders(response);
|
|
674
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
675
|
+
response.end("Anlyx runtime asset not found.");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
function setCorsHeaders(response) {
|
|
679
|
+
response.setHeader("access-control-allow-origin", "*");
|
|
680
|
+
response.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
681
|
+
response.setHeader("access-control-allow-headers", "content-type, x-anlyx-request-id");
|
|
682
|
+
}
|
|
683
|
+
function getServerUrl(port) {
|
|
684
|
+
return `http://localhost:${port}`;
|
|
685
|
+
}
|
|
686
|
+
async function proxyToFrontend(request, response, frontendBaseUrl) {
|
|
687
|
+
const targetUrl = buildProxyTargetUrl(frontendBaseUrl, request.url ?? "/");
|
|
688
|
+
const method = request.method ?? "GET";
|
|
689
|
+
try {
|
|
690
|
+
const requestInit = {
|
|
691
|
+
method,
|
|
692
|
+
headers: getProxyRequestHeaders(request),
|
|
693
|
+
redirect: "manual"
|
|
694
|
+
};
|
|
695
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
696
|
+
const body = await readRequestBody(request);
|
|
697
|
+
if (body !== undefined) {
|
|
698
|
+
requestInit.body = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const upstream = await fetch(targetUrl, requestInit);
|
|
702
|
+
response.statusCode = upstream.status;
|
|
703
|
+
response.statusMessage = upstream.statusText;
|
|
704
|
+
upstream.headers.forEach((value, key) => {
|
|
705
|
+
if (!shouldOmitProxyResponseHeader(key)) {
|
|
706
|
+
response.setHeader(key, value);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
const contentType = upstream.headers.get("content-type") ?? "";
|
|
710
|
+
if (method === "GET" && contentType.includes("text/html")) {
|
|
711
|
+
const html = await upstream.text();
|
|
712
|
+
response.setHeader("content-type", contentType);
|
|
713
|
+
response.end(injectOverlayScript(html));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const body = Buffer.from(await upstream.arrayBuffer());
|
|
717
|
+
response.end(body);
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
response.statusCode = 502;
|
|
721
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
722
|
+
response.end(getProxyErrorHtml(frontendBaseUrl, error));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
export function buildProxyTargetUrl(frontendBaseUrl, requestUrl) {
|
|
726
|
+
const base = new URL(frontendBaseUrl);
|
|
727
|
+
const target = new URL(requestUrl, base);
|
|
728
|
+
target.protocol = base.protocol;
|
|
729
|
+
target.host = base.host;
|
|
730
|
+
return target.toString();
|
|
731
|
+
}
|
|
732
|
+
export function injectOverlayScript(html) {
|
|
733
|
+
if (html.includes("/_anlyx/overlay.js")) {
|
|
734
|
+
return html;
|
|
735
|
+
}
|
|
736
|
+
const script = '<script src="/_anlyx/overlay.js" defer></script>';
|
|
737
|
+
if (html.includes("</body>")) {
|
|
738
|
+
return html.replace("</body>", `${script}</body>`);
|
|
739
|
+
}
|
|
740
|
+
return `${html}${script}`;
|
|
741
|
+
}
|
|
742
|
+
export function getOverlayScriptTag(serverUrl) {
|
|
743
|
+
return `<script src="${serverUrl}/_anlyx/overlay.js" defer></script>`;
|
|
744
|
+
}
|
|
745
|
+
function getInjectModeHtml(frontendBaseUrl, scriptTag) {
|
|
746
|
+
return `<!doctype html>
|
|
747
|
+
<html lang="en">
|
|
748
|
+
<head>
|
|
749
|
+
<meta charset="utf-8" />
|
|
750
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
751
|
+
<title>Anlyx Inject Mode</title>
|
|
752
|
+
<style>
|
|
753
|
+
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; }
|
|
754
|
+
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; }
|
|
755
|
+
h1 { margin: 0 0 10px; font-size: 22px; line-height: 1.25; }
|
|
756
|
+
p { margin: 8px 0; color: #475569; line-height: 1.55; }
|
|
757
|
+
pre { overflow: auto; border-radius: 12px; background: #0f172a; color: #e2e8f0; padding: 14px; font-size: 13px; line-height: 1.5; }
|
|
758
|
+
a { color: #2563eb; font-weight: 800; text-decoration: none; }
|
|
759
|
+
</style>
|
|
760
|
+
</head>
|
|
761
|
+
<body>
|
|
762
|
+
<main>
|
|
763
|
+
<h1>Anlyx runtime is ready</h1>
|
|
764
|
+
<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>
|
|
765
|
+
<pre>${escapeHtml(scriptTag)}</pre>
|
|
766
|
+
<p>The standalone debug viewer is still available at <a href="/_anlyx/viewer">/_anlyx/viewer</a>.</p>
|
|
767
|
+
</main>
|
|
768
|
+
</body>
|
|
769
|
+
</html>`;
|
|
770
|
+
}
|
|
771
|
+
function getProxyRequestHeaders(request) {
|
|
772
|
+
const headers = new Headers();
|
|
773
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
774
|
+
if (value === undefined || shouldOmitProxyRequestHeader(key)) {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (Array.isArray(value)) {
|
|
778
|
+
for (const item of value) {
|
|
779
|
+
headers.append(key, item);
|
|
780
|
+
}
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
headers.set(key, value);
|
|
784
|
+
}
|
|
785
|
+
return headers;
|
|
786
|
+
}
|
|
787
|
+
function shouldOmitProxyRequestHeader(key) {
|
|
788
|
+
return ["host", "connection", "content-length"].includes(key.toLowerCase());
|
|
789
|
+
}
|
|
790
|
+
function shouldOmitProxyResponseHeader(key) {
|
|
791
|
+
return ["connection", "content-encoding", "content-length", "transfer-encoding"].includes(key.toLowerCase());
|
|
792
|
+
}
|
|
793
|
+
function readRequestBody(request) {
|
|
794
|
+
return new Promise((resolveBody, reject) => {
|
|
795
|
+
const chunks = [];
|
|
796
|
+
request.on("data", (chunk) => {
|
|
797
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
798
|
+
});
|
|
799
|
+
request.on("end", () => {
|
|
800
|
+
resolveBody(chunks.length > 0 ? Buffer.concat(chunks) : undefined);
|
|
801
|
+
});
|
|
802
|
+
request.on("error", reject);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
function getProxyErrorHtml(frontendBaseUrl, error) {
|
|
806
|
+
const message = error instanceof Error ? error.message : "Unknown proxy error";
|
|
807
|
+
return `<!doctype html>
|
|
808
|
+
<html lang="en">
|
|
809
|
+
<head>
|
|
810
|
+
<meta charset="utf-8" />
|
|
811
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
812
|
+
<title>Anlyx proxy error</title>
|
|
813
|
+
<style>
|
|
814
|
+
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; }
|
|
815
|
+
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; }
|
|
816
|
+
h1 { margin: 0 0 10px; font-size: 22px; line-height: 1.25; }
|
|
817
|
+
p { margin: 8px 0; color: #475569; line-height: 1.55; }
|
|
818
|
+
code { border-radius: 8px; background: #eef4ff; color: #1d4ed8; padding: 2px 6px; }
|
|
819
|
+
</style>
|
|
820
|
+
</head>
|
|
821
|
+
<body>
|
|
822
|
+
<main>
|
|
823
|
+
<h1>Anlyx could not reach the frontend app</h1>
|
|
824
|
+
<p>Overlay Mode proxies your configured frontend at <code>${escapeHtml(frontendBaseUrl)}</code>.</p>
|
|
825
|
+
<p>Start the frontend dev server, then refresh this page. The standalone viewer is still available at <code>/_anlyx/viewer</code>.</p>
|
|
826
|
+
<p>${escapeHtml(message)}</p>
|
|
827
|
+
</main>
|
|
828
|
+
</body>
|
|
829
|
+
</html>`;
|
|
830
|
+
}
|
|
831
|
+
function escapeHtml(value) {
|
|
832
|
+
return value
|
|
833
|
+
.replace(/&/g, "&")
|
|
834
|
+
.replace(/</g, "<")
|
|
835
|
+
.replace(/>/g, ">")
|
|
836
|
+
.replace(/"/g, """)
|
|
837
|
+
.replace(/'/g, "'");
|
|
838
|
+
}
|
|
839
|
+
export function getOverlayClientScript() {
|
|
840
|
+
return String.raw `
|
|
841
|
+
(() => {
|
|
842
|
+
if (window.__ANLYX_OVERLAY_INSTALLED__) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
window.__ANLYX_OVERLAY_INSTALLED__ = true;
|
|
846
|
+
|
|
847
|
+
const state = {
|
|
848
|
+
report: null,
|
|
849
|
+
events: [],
|
|
850
|
+
actions: [],
|
|
851
|
+
selectedEventId: null,
|
|
852
|
+
open: false,
|
|
853
|
+
loadError: null
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
let drawer = null;
|
|
857
|
+
let body = null;
|
|
858
|
+
let launcher = null;
|
|
859
|
+
let overlayUiReady = false;
|
|
860
|
+
let overlayUiLoading = false;
|
|
861
|
+
let overlayRootGuardInstalled = false;
|
|
862
|
+
let overlayRootRestoreScheduled = false;
|
|
863
|
+
let overlayInfrastructureInstalled = false;
|
|
864
|
+
const endpointRegexCache = new Map();
|
|
865
|
+
const currentScript = document.currentScript;
|
|
866
|
+
const runtimeBaseUrl = currentScript && currentScript.src ? new URL(currentScript.src).origin : window.location.origin;
|
|
867
|
+
const ANLYX_PENDING_ACTION_KEY = "__anlyx_pending_action__";
|
|
868
|
+
const ANLYX_DRAWER_SETTINGS_KEY = "__anlyx_drawer_settings__";
|
|
869
|
+
const ANLYX_LAUNCHER_SETTINGS_KEY = "__anlyx_launcher_settings__";
|
|
870
|
+
const drawerSettings = Object.assign({
|
|
871
|
+
width: 600,
|
|
872
|
+
height: 760,
|
|
873
|
+
x: null,
|
|
874
|
+
y: 12,
|
|
875
|
+
opacity: 0.98,
|
|
876
|
+
language: "en"
|
|
877
|
+
}, restoreDrawerSettings());
|
|
878
|
+
const launcherSettings = Object.assign({
|
|
879
|
+
x: null,
|
|
880
|
+
y: null,
|
|
881
|
+
expandedUntil: 0
|
|
882
|
+
}, restoreLauncherSettings());
|
|
883
|
+
|
|
884
|
+
scheduleOverlayMount();
|
|
885
|
+
|
|
886
|
+
function scheduleOverlayMount() {
|
|
887
|
+
const mount = () => window.setTimeout(mountOverlayUi, 0);
|
|
888
|
+
|
|
889
|
+
if (document.body) {
|
|
890
|
+
mount();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (document.readyState === "loading") {
|
|
895
|
+
document.addEventListener("DOMContentLoaded", mount, { once: true });
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
mount();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function mountOverlayUi() {
|
|
903
|
+
const existingRoot = document.getElementById("anlyx-overlay-root");
|
|
904
|
+
if (existingRoot) {
|
|
905
|
+
launcher = existingRoot.querySelector(".anlyx-fab");
|
|
906
|
+
drawer = existingRoot.querySelector(".anlyx-drawer");
|
|
907
|
+
body = existingRoot.querySelector(".anlyx-body");
|
|
908
|
+
if (drawer && body) {
|
|
909
|
+
installOverlayRootGuard();
|
|
910
|
+
applyLauncherSettings();
|
|
911
|
+
render();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
existingRoot.remove();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (!document.body) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (!document.querySelector("style[data-anlyx-overlay-base]")) {
|
|
922
|
+
const style = document.createElement("style");
|
|
923
|
+
style.setAttribute("data-anlyx-overlay-base", "true");
|
|
924
|
+
style.textContent = ${"`"}
|
|
925
|
+
#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; }
|
|
926
|
+
.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; }
|
|
927
|
+
.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); }
|
|
928
|
+
.anlyx-fab:active { cursor: grabbing; transform: translateY(0); }
|
|
929
|
+
.anlyx-fab__mark { width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 38px; }
|
|
930
|
+
.anlyx-fab__mark svg { width: 22px; height: 22px; display: block; filter: drop-shadow(0 1px 1px rgba(15, 23, 42, .16)); }
|
|
931
|
+
.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; }
|
|
932
|
+
.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); }
|
|
933
|
+
.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; }
|
|
934
|
+
.anlyx-drawer[data-open="true"] { display: grid; grid-template-rows: auto minmax(0, 1fr); }
|
|
935
|
+
.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); }
|
|
936
|
+
.anlyx-drag-handle { min-width: 0; cursor: grab; user-select: none; }
|
|
937
|
+
.anlyx-drag-handle:active { cursor: grabbing; }
|
|
938
|
+
.anlyx-title { margin: 0; font-size: 15px; line-height: 1.2; font-weight: 900; letter-spacing: 0; }
|
|
939
|
+
.anlyx-subtitle { margin: 3px 0 0; font-size: 11px; color: #64748b; font-weight: 650; }
|
|
940
|
+
.anlyx-shell-controls { display: flex; align-items: center; gap: 7px; }
|
|
941
|
+
.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; }
|
|
942
|
+
.anlyx-opacity-control { width: 70px; accent-color: #2563eb; }
|
|
943
|
+
.anlyx-language-control { width: 58px; border: 0; outline: 0; background: transparent; color: #0f172a; font-size: 10px; font-weight: 900; }
|
|
944
|
+
.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); }
|
|
945
|
+
.anlyx-body { overflow: auto; padding: 12px; background: #f8fafc; }
|
|
946
|
+
.anlyx-resize-handle { position: absolute; left: 0; bottom: 0; width: 22px; height: 22px; cursor: nesw-resize; opacity: .62; }
|
|
947
|
+
.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; }
|
|
948
|
+
.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); }
|
|
949
|
+
.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; }
|
|
950
|
+
.anlyx-empty { padding: 18px 12px; color: #667085; font-size: 13px; line-height: 1.5; }
|
|
951
|
+
@media (max-width: 700px) {
|
|
952
|
+
.anlyx-drawer { min-width: 0; width: calc(100vw - 16px); height: calc(100vh - 16px); border-radius: 14px; }
|
|
953
|
+
.anlyx-head { grid-template-columns: 1fr; }
|
|
954
|
+
.anlyx-shell-controls { justify-content: space-between; }
|
|
955
|
+
}
|
|
956
|
+
${"`"};
|
|
957
|
+
document.head.appendChild(style);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const root = document.createElement("div");
|
|
961
|
+
root.id = "anlyx-overlay-root";
|
|
962
|
+
root.innerHTML = ${"`"}
|
|
963
|
+
<button class="anlyx-fab" type="button" aria-label="Open Anlyx" title="Open Anlyx">
|
|
964
|
+
<span class="anlyx-fab__mark" aria-hidden="true">
|
|
965
|
+
<svg viewBox="0 0 32 32" role="img" focusable="false">
|
|
966
|
+
<circle cx="8" cy="7" r="4.3" fill="none" stroke="currentColor" stroke-width="2.7" />
|
|
967
|
+
<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" />
|
|
968
|
+
<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" />
|
|
969
|
+
<rect x="20.2" y="21.2" width="6.4" height="6.4" rx="1.8" fill="none" stroke="currentColor" stroke-width="2.7" />
|
|
970
|
+
<rect x="15" y="13.1" width="5.2" height="5.2" rx="1" fill="#f59e0b" transform="rotate(45 17.6 15.7)" />
|
|
971
|
+
</svg>
|
|
972
|
+
</span>
|
|
973
|
+
<span class="anlyx-fab__label">Anlyx</span>
|
|
974
|
+
</button>
|
|
975
|
+
<aside class="anlyx-drawer" aria-label="Anlyx flow drawer">
|
|
976
|
+
<div class="anlyx-head">
|
|
977
|
+
<div class="anlyx-drag-handle" data-anlyx-label="Move Anlyx drawer">
|
|
978
|
+
<h2 class="anlyx-title">Anlyx Flow Drawer</h2>
|
|
979
|
+
<p class="anlyx-subtitle">Click the real app and inspect the API flow.</p>
|
|
980
|
+
</div>
|
|
981
|
+
<div class="anlyx-shell-controls">
|
|
982
|
+
<label class="anlyx-shell-field">
|
|
983
|
+
<span class="anlyx-opacity-label">Opacity</span>
|
|
984
|
+
<input class="anlyx-opacity-control" type="range" min="70" max="100" step="5" aria-label="Anlyx opacity" />
|
|
985
|
+
</label>
|
|
986
|
+
<label class="anlyx-shell-field">
|
|
987
|
+
<span class="anlyx-language-label">Lang</span>
|
|
988
|
+
<select class="anlyx-language-control" aria-label="Anlyx language">
|
|
989
|
+
<option value="en">EN</option>
|
|
990
|
+
<option value="ko">KO</option>
|
|
991
|
+
</select>
|
|
992
|
+
</label>
|
|
993
|
+
<button class="anlyx-close" type="button" aria-label="Close Anlyx">×</button>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
<div class="anlyx-body"></div>
|
|
997
|
+
<div class="anlyx-resize-handle" role="separator" aria-label="Resize Anlyx drawer"></div>
|
|
998
|
+
</aside>
|
|
999
|
+
${"`"};
|
|
1000
|
+
document.body.appendChild(root);
|
|
1001
|
+
|
|
1002
|
+
const button = root.querySelector(".anlyx-fab");
|
|
1003
|
+
launcher = button;
|
|
1004
|
+
drawer = root.querySelector(".anlyx-drawer");
|
|
1005
|
+
body = root.querySelector(".anlyx-body");
|
|
1006
|
+
const closeButton = root.querySelector(".anlyx-close");
|
|
1007
|
+
const opacityControl = root.querySelector(".anlyx-opacity-control");
|
|
1008
|
+
const languageControl = root.querySelector(".anlyx-language-control");
|
|
1009
|
+
const dragHandle = root.querySelector(".anlyx-drag-handle");
|
|
1010
|
+
const resizeHandle = root.querySelector(".anlyx-resize-handle");
|
|
1011
|
+
|
|
1012
|
+
installLauncherDrag(button);
|
|
1013
|
+
button.addEventListener("click", () => {
|
|
1014
|
+
if (button.__anlyxSuppressClick) {
|
|
1015
|
+
button.__anlyxSuppressClick = false;
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
state.open = !state.open;
|
|
1019
|
+
render();
|
|
1020
|
+
});
|
|
1021
|
+
closeButton.addEventListener("click", () => {
|
|
1022
|
+
state.open = false;
|
|
1023
|
+
render();
|
|
1024
|
+
});
|
|
1025
|
+
if (opacityControl) {
|
|
1026
|
+
opacityControl.addEventListener("input", () => {
|
|
1027
|
+
drawerSettings.opacity = Number(opacityControl.value || 98) / 100;
|
|
1028
|
+
applyDrawerSettings();
|
|
1029
|
+
persistDrawerSettings();
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
if (languageControl) {
|
|
1033
|
+
languageControl.addEventListener("change", () => {
|
|
1034
|
+
drawerSettings.language = languageControl.value === "ko" ? "ko" : "en";
|
|
1035
|
+
applyDrawerSettings();
|
|
1036
|
+
persistDrawerSettings();
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
installDrawerDrag(dragHandle);
|
|
1040
|
+
installDrawerResize(resizeHandle);
|
|
1041
|
+
applyDrawerSettings();
|
|
1042
|
+
applyLauncherSettings();
|
|
1043
|
+
|
|
1044
|
+
installOverlayRootGuard();
|
|
1045
|
+
|
|
1046
|
+
if (!overlayInfrastructureInstalled) {
|
|
1047
|
+
overlayInfrastructureInstalled = true;
|
|
1048
|
+
restorePendingAction();
|
|
1049
|
+
installUserActionTracker(root);
|
|
1050
|
+
installFetchInterceptor();
|
|
1051
|
+
installXhrInterceptor();
|
|
1052
|
+
installPageContextReporter();
|
|
1053
|
+
loadReport();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
render();
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function applyDrawerSettings() {
|
|
1060
|
+
if (!drawer) {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const viewportWidth = window.innerWidth || 1280;
|
|
1064
|
+
const viewportHeight = window.innerHeight || 800;
|
|
1065
|
+
const width = clamp(Number(drawerSettings.width) || 600, Math.min(420, viewportWidth - 16), viewportWidth - 16);
|
|
1066
|
+
const height = clamp(Number(drawerSettings.height) || 760, Math.min(420, viewportHeight - 16), viewportHeight - 16);
|
|
1067
|
+
const defaultX = viewportWidth - width - 12;
|
|
1068
|
+
const x = clamp(drawerSettings.x === null ? defaultX : Number(drawerSettings.x), 8, viewportWidth - width - 8);
|
|
1069
|
+
const y = clamp(Number(drawerSettings.y) || 12, 8, viewportHeight - height - 8);
|
|
1070
|
+
drawerSettings.width = Math.round(width);
|
|
1071
|
+
drawerSettings.height = Math.round(height);
|
|
1072
|
+
drawerSettings.x = Math.round(x);
|
|
1073
|
+
drawerSettings.y = Math.round(y);
|
|
1074
|
+
drawerSettings.opacity = clamp(Number(drawerSettings.opacity) || 0.98, 0.7, 1);
|
|
1075
|
+
drawer.style.width = drawerSettings.width + "px";
|
|
1076
|
+
drawer.style.height = drawerSettings.height + "px";
|
|
1077
|
+
drawer.style.left = drawerSettings.x + "px";
|
|
1078
|
+
drawer.style.top = drawerSettings.y + "px";
|
|
1079
|
+
drawer.style.opacity = String(drawerSettings.opacity);
|
|
1080
|
+
|
|
1081
|
+
const opacityControl = document.querySelector("#anlyx-overlay-root .anlyx-opacity-control");
|
|
1082
|
+
const languageControl = document.querySelector("#anlyx-overlay-root .anlyx-language-control");
|
|
1083
|
+
if (opacityControl) {
|
|
1084
|
+
opacityControl.value = String(Math.round(drawerSettings.opacity * 100));
|
|
1085
|
+
}
|
|
1086
|
+
if (languageControl) {
|
|
1087
|
+
languageControl.value = drawerSettings.language === "ko" ? "ko" : "en";
|
|
1088
|
+
}
|
|
1089
|
+
applyDrawerLanguage();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function applyLauncherSettings() {
|
|
1093
|
+
if (!launcher) {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const viewportWidth = window.innerWidth || 1280;
|
|
1097
|
+
const viewportHeight = window.innerHeight || 800;
|
|
1098
|
+
const width = 38;
|
|
1099
|
+
const height = 38;
|
|
1100
|
+
const defaultX = viewportWidth - width - 18;
|
|
1101
|
+
const defaultY = viewportHeight - height - 18;
|
|
1102
|
+
const x = clamp(launcherSettings.x === null ? defaultX : Number(launcherSettings.x), 8, viewportWidth - width - 8);
|
|
1103
|
+
const y = clamp(launcherSettings.y === null ? defaultY : Number(launcherSettings.y), 8, viewportHeight - height - 8);
|
|
1104
|
+
launcherSettings.x = Math.round(x);
|
|
1105
|
+
launcherSettings.y = Math.round(y);
|
|
1106
|
+
launcher.style.left = launcherSettings.x + "px";
|
|
1107
|
+
launcher.style.top = launcherSettings.y + "px";
|
|
1108
|
+
launcher.style.right = "auto";
|
|
1109
|
+
launcher.style.bottom = "auto";
|
|
1110
|
+
launcher.dataset.expanded = Date.now() < Number(launcherSettings.expandedUntil || 0) ? "true" : "false";
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function applyDrawerLanguage() {
|
|
1114
|
+
const isKo = drawerSettings.language === "ko";
|
|
1115
|
+
const title = document.querySelector("#anlyx-overlay-root .anlyx-title");
|
|
1116
|
+
const subtitle = document.querySelector("#anlyx-overlay-root .anlyx-subtitle");
|
|
1117
|
+
const opacityLabel = document.querySelector("#anlyx-overlay-root .anlyx-opacity-label");
|
|
1118
|
+
const languageLabel = document.querySelector("#anlyx-overlay-root .anlyx-language-label");
|
|
1119
|
+
if (title) {
|
|
1120
|
+
title.textContent = isKo ? "Anlyx 플로우 드로어" : "Anlyx Flow Drawer";
|
|
1121
|
+
}
|
|
1122
|
+
if (subtitle) {
|
|
1123
|
+
subtitle.textContent = isKo ? "앱을 그대로 사용하며 API 흐름을 확인하세요." : "Click the real app and inspect the API flow.";
|
|
1124
|
+
}
|
|
1125
|
+
if (opacityLabel) {
|
|
1126
|
+
opacityLabel.textContent = isKo ? "투명도" : "Opacity";
|
|
1127
|
+
}
|
|
1128
|
+
if (languageLabel) {
|
|
1129
|
+
languageLabel.textContent = isKo ? "언어" : "Lang";
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function installDrawerDrag(handle) {
|
|
1134
|
+
if (!handle || !drawer) {
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
handle.addEventListener("pointerdown", (event) => {
|
|
1138
|
+
if (event.button !== 0) {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const startX = event.clientX;
|
|
1142
|
+
const startY = event.clientY;
|
|
1143
|
+
const initialX = Number(drawerSettings.x) || drawer.getBoundingClientRect().left;
|
|
1144
|
+
const initialY = Number(drawerSettings.y) || drawer.getBoundingClientRect().top;
|
|
1145
|
+
const onMove = (moveEvent) => {
|
|
1146
|
+
drawerSettings.x = initialX + moveEvent.clientX - startX;
|
|
1147
|
+
drawerSettings.y = initialY + moveEvent.clientY - startY;
|
|
1148
|
+
applyDrawerSettings();
|
|
1149
|
+
};
|
|
1150
|
+
const onUp = () => {
|
|
1151
|
+
window.removeEventListener("pointermove", onMove);
|
|
1152
|
+
window.removeEventListener("pointerup", onUp);
|
|
1153
|
+
persistDrawerSettings();
|
|
1154
|
+
};
|
|
1155
|
+
window.addEventListener("pointermove", onMove);
|
|
1156
|
+
window.addEventListener("pointerup", onUp, { once: true });
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function installDrawerResize(handle) {
|
|
1161
|
+
if (!handle || !drawer) {
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
handle.addEventListener("pointerdown", (event) => {
|
|
1165
|
+
if (event.button !== 0) {
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
event.preventDefault();
|
|
1169
|
+
const startX = event.clientX;
|
|
1170
|
+
const startY = event.clientY;
|
|
1171
|
+
const initialX = Number(drawerSettings.x) || drawer.getBoundingClientRect().left;
|
|
1172
|
+
const initialWidth = Number(drawerSettings.width) || drawer.getBoundingClientRect().width;
|
|
1173
|
+
const initialHeight = Number(drawerSettings.height) || drawer.getBoundingClientRect().height;
|
|
1174
|
+
const onMove = (moveEvent) => {
|
|
1175
|
+
const width = initialWidth - (moveEvent.clientX - startX);
|
|
1176
|
+
drawerSettings.width = width;
|
|
1177
|
+
drawerSettings.height = initialHeight + moveEvent.clientY - startY;
|
|
1178
|
+
drawerSettings.x = initialX + initialWidth - width;
|
|
1179
|
+
applyDrawerSettings();
|
|
1180
|
+
};
|
|
1181
|
+
const onUp = () => {
|
|
1182
|
+
window.removeEventListener("pointermove", onMove);
|
|
1183
|
+
window.removeEventListener("pointerup", onUp);
|
|
1184
|
+
persistDrawerSettings();
|
|
1185
|
+
};
|
|
1186
|
+
window.addEventListener("pointermove", onMove);
|
|
1187
|
+
window.addEventListener("pointerup", onUp, { once: true });
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function installLauncherDrag(button) {
|
|
1192
|
+
if (!button) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
button.addEventListener("pointerdown", (event) => {
|
|
1196
|
+
if (event.button !== 0) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
const startX = event.clientX;
|
|
1200
|
+
const startY = event.clientY;
|
|
1201
|
+
const initialX = launcherSettings.x === null ? button.getBoundingClientRect().left : Number(launcherSettings.x);
|
|
1202
|
+
const initialY = launcherSettings.y === null ? button.getBoundingClientRect().top : Number(launcherSettings.y);
|
|
1203
|
+
let dragged = false;
|
|
1204
|
+
const onMove = (moveEvent) => {
|
|
1205
|
+
const deltaX = moveEvent.clientX - startX;
|
|
1206
|
+
const deltaY = moveEvent.clientY - startY;
|
|
1207
|
+
if (Math.abs(deltaX) + Math.abs(deltaY) > 4) {
|
|
1208
|
+
dragged = true;
|
|
1209
|
+
}
|
|
1210
|
+
if (!dragged) {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
launcherSettings.x = initialX + deltaX;
|
|
1214
|
+
launcherSettings.y = initialY + deltaY;
|
|
1215
|
+
applyLauncherSettings();
|
|
1216
|
+
};
|
|
1217
|
+
const onUp = () => {
|
|
1218
|
+
window.removeEventListener("pointermove", onMove);
|
|
1219
|
+
window.removeEventListener("pointerup", onUp);
|
|
1220
|
+
if (dragged) {
|
|
1221
|
+
button.__anlyxSuppressClick = true;
|
|
1222
|
+
persistLauncherSettings();
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
window.addEventListener("pointermove", onMove);
|
|
1226
|
+
window.addEventListener("pointerup", onUp, { once: true });
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function restoreDrawerSettings() {
|
|
1231
|
+
try {
|
|
1232
|
+
const raw = window.localStorage && window.localStorage.getItem(ANLYX_DRAWER_SETTINGS_KEY);
|
|
1233
|
+
return raw ? JSON.parse(raw) : {};
|
|
1234
|
+
} catch {
|
|
1235
|
+
return {};
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function restoreLauncherSettings() {
|
|
1240
|
+
try {
|
|
1241
|
+
const raw = window.localStorage && window.localStorage.getItem(ANLYX_LAUNCHER_SETTINGS_KEY);
|
|
1242
|
+
return raw ? JSON.parse(raw) : {};
|
|
1243
|
+
} catch {
|
|
1244
|
+
return {};
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function persistDrawerSettings() {
|
|
1249
|
+
try {
|
|
1250
|
+
if (window.localStorage) {
|
|
1251
|
+
window.localStorage.setItem(ANLYX_DRAWER_SETTINGS_KEY, JSON.stringify(drawerSettings));
|
|
1252
|
+
}
|
|
1253
|
+
} catch {
|
|
1254
|
+
// Ignore storage failures in strict browser privacy modes.
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function persistLauncherSettings() {
|
|
1259
|
+
try {
|
|
1260
|
+
if (window.localStorage) {
|
|
1261
|
+
window.localStorage.setItem(ANLYX_LAUNCHER_SETTINGS_KEY, JSON.stringify({
|
|
1262
|
+
x: launcherSettings.x,
|
|
1263
|
+
y: launcherSettings.y
|
|
1264
|
+
}));
|
|
1265
|
+
}
|
|
1266
|
+
} catch {
|
|
1267
|
+
// Ignore storage failures in strict browser privacy modes.
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function clamp(value, min, max) {
|
|
1272
|
+
return Math.min(Math.max(value, min), max);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function installOverlayRootGuard() {
|
|
1276
|
+
if (overlayRootGuardInstalled || !document.body || !window.MutationObserver) {
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
overlayRootGuardInstalled = true;
|
|
1280
|
+
const observer = new MutationObserver(() => {
|
|
1281
|
+
if (!document.body || document.getElementById("anlyx-overlay-root") || overlayRootRestoreScheduled) {
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
overlayRootRestoreScheduled = true;
|
|
1285
|
+
window.setTimeout(() => {
|
|
1286
|
+
overlayRootRestoreScheduled = false;
|
|
1287
|
+
mountOverlayUi();
|
|
1288
|
+
}, 50);
|
|
1289
|
+
});
|
|
1290
|
+
observer.observe(document.body, { childList: true });
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function loadOverlayUiAssets() {
|
|
1294
|
+
if (!document.querySelector("link[data-anlyx-overlay-ui]")) {
|
|
1295
|
+
const link = document.createElement("link");
|
|
1296
|
+
link.rel = "stylesheet";
|
|
1297
|
+
link.href = runtimeBaseUrl + "/_anlyx/overlay-ui.css";
|
|
1298
|
+
link.setAttribute("data-anlyx-overlay-ui", "true");
|
|
1299
|
+
document.head.appendChild(link);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
if (window.__ANLYX_RENDER_FLOW_DRAWER__) {
|
|
1303
|
+
overlayUiReady = true;
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
if (overlayUiLoading) {
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
overlayUiLoading = true;
|
|
1312
|
+
const script = document.createElement("script");
|
|
1313
|
+
script.src = runtimeBaseUrl + "/_anlyx/overlay-ui.js";
|
|
1314
|
+
script.defer = true;
|
|
1315
|
+
script.onload = () => {
|
|
1316
|
+
overlayUiReady = true;
|
|
1317
|
+
render();
|
|
1318
|
+
};
|
|
1319
|
+
script.onerror = () => {
|
|
1320
|
+
overlayUiReady = false;
|
|
1321
|
+
render();
|
|
1322
|
+
};
|
|
1323
|
+
document.head.appendChild(script);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
async function loadReport() {
|
|
1327
|
+
try {
|
|
1328
|
+
const response = await fetch(runtimeBaseUrl + "/_anlyx/report-data");
|
|
1329
|
+
if (!response.ok) {
|
|
1330
|
+
throw new Error("Report data request failed with status " + response.status);
|
|
1331
|
+
}
|
|
1332
|
+
state.report = await response.json();
|
|
1333
|
+
render();
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
state.loadError = error instanceof Error ? error.message : "Failed to load report data";
|
|
1336
|
+
render();
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function installFetchInterceptor() {
|
|
1341
|
+
const originalFetch = window.fetch;
|
|
1342
|
+
window.fetch = async function anlyxFetch(input, init) {
|
|
1343
|
+
const method = ((init && init.method) || (input && input.method) || "GET").toUpperCase();
|
|
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;
|
|
1348
|
+
const startedAt = performance.now();
|
|
1349
|
+
try {
|
|
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 });
|
|
1353
|
+
}
|
|
1354
|
+
return response;
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
if (shouldTrack) {
|
|
1357
|
+
scheduleApiEventRecord({ id: requestId, method, url, status: "failed", durationMs: performance.now() - startedAt, startedAt });
|
|
1358
|
+
}
|
|
1359
|
+
throw error;
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
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
|
+
|
|
1388
|
+
function installUserActionTracker(root) {
|
|
1389
|
+
document.addEventListener("pointerdown", (event) => captureUserAction(event, root), true);
|
|
1390
|
+
document.addEventListener("click", (event) => captureUserAction(event, root), true);
|
|
1391
|
+
document.addEventListener("submit", (event) => captureUserAction(event, root), true);
|
|
1392
|
+
document.addEventListener("keydown", (event) => {
|
|
1393
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1394
|
+
captureUserAction(event, root);
|
|
1395
|
+
}
|
|
1396
|
+
}, true);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function captureUserAction(event, root) {
|
|
1400
|
+
const target = getActionTarget(event.target);
|
|
1401
|
+
if (!target || root.contains(target)) {
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const action = {
|
|
1406
|
+
id: String(Date.now()) + "-" + Math.random().toString(36).slice(2),
|
|
1407
|
+
type: getActionType(event, target),
|
|
1408
|
+
label: getActionLabel(target),
|
|
1409
|
+
selector: getElementPath(target),
|
|
1410
|
+
at: performance.now(),
|
|
1411
|
+
capturedAt: Date.now()
|
|
1412
|
+
};
|
|
1413
|
+
rememberAction(action);
|
|
1414
|
+
persistPendingAction(action);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function rememberAction(action) {
|
|
1418
|
+
state.actions = [action].concat(state.actions).slice(0, 20);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function persistPendingAction(action) {
|
|
1422
|
+
try {
|
|
1423
|
+
window.sessionStorage.setItem(ANLYX_PENDING_ACTION_KEY, JSON.stringify(action));
|
|
1424
|
+
} catch {
|
|
1425
|
+
// Ignore storage failures in strict browser privacy modes.
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function restorePendingAction() {
|
|
1430
|
+
try {
|
|
1431
|
+
const raw = window.sessionStorage.getItem(ANLYX_PENDING_ACTION_KEY);
|
|
1432
|
+
if (!raw) {
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const action = JSON.parse(raw);
|
|
1436
|
+
if (isFreshAction(action)) {
|
|
1437
|
+
rememberAction(Object.assign({}, action, { at: performance.now() }));
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
window.sessionStorage.removeItem(ANLYX_PENDING_ACTION_KEY);
|
|
1441
|
+
} catch {
|
|
1442
|
+
try {
|
|
1443
|
+
window.sessionStorage.removeItem(ANLYX_PENDING_ACTION_KEY);
|
|
1444
|
+
} catch {
|
|
1445
|
+
// Ignore storage cleanup failures.
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function isFreshAction(action) {
|
|
1451
|
+
return action && action.capturedAt && Date.now() - action.capturedAt <= 8000;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function getActionTarget(target) {
|
|
1455
|
+
if (!target || !target.closest) {
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
return target.closest("[data-anlyx-label], button, a, input, select, textarea, label, summary, [role='button'], [role='link'], [role='menuitem'], [role='tab']");
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function getActionType(event, target) {
|
|
1462
|
+
if (event.type === "submit") {
|
|
1463
|
+
return "Submitted";
|
|
1464
|
+
}
|
|
1465
|
+
if (event.type === "keydown") {
|
|
1466
|
+
return "Pressed";
|
|
1467
|
+
}
|
|
1468
|
+
if (target.tagName === "A" || target.getAttribute("role") === "link") {
|
|
1469
|
+
return "Opened";
|
|
1470
|
+
}
|
|
1471
|
+
return "Clicked";
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function getActionLabel(target) {
|
|
1475
|
+
const label =
|
|
1476
|
+
target.getAttribute("data-anlyx-label") ||
|
|
1477
|
+
target.getAttribute("aria-label") ||
|
|
1478
|
+
target.getAttribute("title") ||
|
|
1479
|
+
target.getAttribute("placeholder") ||
|
|
1480
|
+
target.name ||
|
|
1481
|
+
target.textContent ||
|
|
1482
|
+
target.value ||
|
|
1483
|
+
getElementPath(target);
|
|
1484
|
+
return compactLabel(label);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function compactLabel(value) {
|
|
1488
|
+
const label = String(value || "").replace(/\s+/g, " ").trim();
|
|
1489
|
+
if (!label) {
|
|
1490
|
+
return "unnamed element";
|
|
1491
|
+
}
|
|
1492
|
+
return label.length > 80 ? label.slice(0, 77) + "..." : label;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function getElementPath(target) {
|
|
1496
|
+
const tag = String(target.tagName || "element").toLowerCase();
|
|
1497
|
+
if (target.id) {
|
|
1498
|
+
return tag + "#" + target.id;
|
|
1499
|
+
}
|
|
1500
|
+
const testId = target.getAttribute("data-testid");
|
|
1501
|
+
if (testId) {
|
|
1502
|
+
return tag + "[data-testid='" + testId + "']";
|
|
1503
|
+
}
|
|
1504
|
+
const className = String(target.className || "").split(/\s+/).filter(Boolean).slice(0, 2).join(".");
|
|
1505
|
+
return className ? tag + "." + className : tag;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function installXhrInterceptor() {
|
|
1509
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
1510
|
+
const originalSend = XMLHttpRequest.prototype.send;
|
|
1511
|
+
|
|
1512
|
+
XMLHttpRequest.prototype.open = function anlyxOpen(method, url) {
|
|
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
|
+
};
|
|
1520
|
+
return originalOpen.apply(this, arguments);
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
XMLHttpRequest.prototype.send = function anlyxSend() {
|
|
1524
|
+
const request = this.__anlyxRequest;
|
|
1525
|
+
if (request) {
|
|
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
|
+
}
|
|
1534
|
+
this.addEventListener("loadend", () => {
|
|
1535
|
+
if (shouldTrackRequestUrl(request.url)) {
|
|
1536
|
+
scheduleApiEventRecord({
|
|
1537
|
+
id: request.id,
|
|
1538
|
+
method: request.method,
|
|
1539
|
+
url: request.url,
|
|
1540
|
+
status: this.status || "unknown",
|
|
1541
|
+
durationMs: performance.now() - request.startedAt,
|
|
1542
|
+
startedAt: request.startedAt
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
return originalSend.apply(this, arguments);
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function scheduleApiEventRecord(event) {
|
|
1552
|
+
window.setTimeout(() => recordApiEvent(event), 0);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function recordApiEvent(event) {
|
|
1556
|
+
const normalized = normalizeUrl(event.url);
|
|
1557
|
+
if (!normalized || shouldIgnoreRequest(normalized)) {
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const matched = matchEndpoint(event.method, normalized.pathname);
|
|
1562
|
+
const passive = isPassiveRequest(event.method, normalized.pathname);
|
|
1563
|
+
const triggeredBy = passive ? null : findActionForRequest(event.startedAt);
|
|
1564
|
+
const item = {
|
|
1565
|
+
id: event.id || createRequestId(),
|
|
1566
|
+
method: event.method,
|
|
1567
|
+
path: normalized.pathname,
|
|
1568
|
+
status: event.status,
|
|
1569
|
+
durationMs: Math.round(event.durationMs),
|
|
1570
|
+
count: 1,
|
|
1571
|
+
contextId: getCurrentContextId(),
|
|
1572
|
+
lastSeenAt: Date.now(),
|
|
1573
|
+
triggeredBy,
|
|
1574
|
+
priority: passive ? "background" : "primary",
|
|
1575
|
+
source: triggeredBy ? "action" : classifyApiEventSource(normalized.pathname),
|
|
1576
|
+
matchedEndpoint: matched.endpoint,
|
|
1577
|
+
matchedFlow: matched.flow,
|
|
1578
|
+
matchedPages: matched.pages
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1581
|
+
const existingIndex = findExistingEventIndex(item);
|
|
1582
|
+
if (existingIndex >= 0) {
|
|
1583
|
+
const existing = state.events[existingIndex];
|
|
1584
|
+
const updated = Object.assign({}, existing, {
|
|
1585
|
+
status: item.status,
|
|
1586
|
+
durationMs: item.durationMs,
|
|
1587
|
+
count: (existing.count || 1) + 1,
|
|
1588
|
+
contextId: item.contextId,
|
|
1589
|
+
lastSeenAt: item.lastSeenAt,
|
|
1590
|
+
triggeredBy: item.triggeredBy || existing.triggeredBy,
|
|
1591
|
+
priority: item.priority,
|
|
1592
|
+
source: item.triggeredBy ? "action" : item.source,
|
|
1593
|
+
matchedEndpoint: item.matchedEndpoint,
|
|
1594
|
+
matchedFlow: item.matchedFlow,
|
|
1595
|
+
matchedPages: item.matchedPages
|
|
1596
|
+
});
|
|
1597
|
+
state.events = [updated].concat(state.events.filter((_, index) => index !== existingIndex)).slice(0, 12);
|
|
1598
|
+
if (shouldAutoFocusEvent(updated)) {
|
|
1599
|
+
brieflyExpandLauncher();
|
|
1600
|
+
state.selectedEventId = updated.id;
|
|
1601
|
+
state.open = true;
|
|
1602
|
+
}
|
|
1603
|
+
render();
|
|
1604
|
+
sendBrowserRequestEvent(updated);
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
state.events = [item].concat(state.events).slice(0, 12);
|
|
1609
|
+
if (shouldAutoFocusEvent(item)) {
|
|
1610
|
+
brieflyExpandLauncher();
|
|
1611
|
+
state.selectedEventId = item.id;
|
|
1612
|
+
state.open = true;
|
|
1613
|
+
}
|
|
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
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function shouldAutoFocusEvent(item) {
|
|
1656
|
+
return Boolean(item && item.triggeredBy);
|
|
1657
|
+
}
|
|
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
|
+
|
|
1733
|
+
function brieflyExpandLauncher() {
|
|
1734
|
+
launcherSettings.expandedUntil = Date.now() + 2600;
|
|
1735
|
+
applyLauncherSettings();
|
|
1736
|
+
window.setTimeout(applyLauncherSettings, 2700);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function classifyApiEventSource(pathname) {
|
|
1740
|
+
if (isHealthOrPollingPath(pathname)) {
|
|
1741
|
+
return "health";
|
|
1742
|
+
}
|
|
1743
|
+
return "background";
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function isPassiveRequest(method, pathname) {
|
|
1747
|
+
const normalizedMethod = String(method || "GET").toUpperCase();
|
|
1748
|
+
const segments = getPathSegments(pathname);
|
|
1749
|
+
if (isHealthOrPollingPath(pathname)) {
|
|
1750
|
+
return true;
|
|
1751
|
+
}
|
|
1752
|
+
if (segments.some((segment) => {
|
|
1753
|
+
return segment === "page-views" ||
|
|
1754
|
+
segment === "analytics" ||
|
|
1755
|
+
segment === "telemetry" ||
|
|
1756
|
+
segment === "events" ||
|
|
1757
|
+
segment === "metrics";
|
|
1758
|
+
})) {
|
|
1759
|
+
return true;
|
|
1760
|
+
}
|
|
1761
|
+
if (isAutomaticSupportPath(normalizedMethod, segments)) {
|
|
1762
|
+
return true;
|
|
1763
|
+
}
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
function isAutomaticSupportPath(method, segments) {
|
|
1768
|
+
if (method === "GET" && isSessionProbePath(segments)) {
|
|
1769
|
+
return true;
|
|
1770
|
+
}
|
|
1771
|
+
if (segments.includes("csrf") || segments.includes("xsrf")) {
|
|
1772
|
+
return true;
|
|
1773
|
+
}
|
|
1774
|
+
if (!segments.includes("auth")) {
|
|
1775
|
+
return false;
|
|
1776
|
+
}
|
|
1777
|
+
const last = segments[segments.length - 1] || "";
|
|
1778
|
+
return last === "session" ||
|
|
1779
|
+
last === "refresh" ||
|
|
1780
|
+
last === "token" ||
|
|
1781
|
+
last === "csrf" ||
|
|
1782
|
+
last === "status";
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function isSessionProbePath(segments) {
|
|
1786
|
+
const last = segments[segments.length - 1] || "";
|
|
1787
|
+
if (last === "me" || last === "session" || last === "profile" || last === "current-user") {
|
|
1788
|
+
return true;
|
|
1789
|
+
}
|
|
1790
|
+
return segments.includes("saved-benefits") ||
|
|
1791
|
+
segments.includes("saved-items") ||
|
|
1792
|
+
segments.includes("bookmarks") ||
|
|
1793
|
+
segments.includes("favorites");
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function isHealthOrPollingPath(pathname) {
|
|
1797
|
+
const segments = getPathSegments(pathname);
|
|
1798
|
+
return segments.some((segment) => {
|
|
1799
|
+
return segment === "health" ||
|
|
1800
|
+
segment === "healthz" ||
|
|
1801
|
+
segment === "ready" ||
|
|
1802
|
+
segment === "readyz" ||
|
|
1803
|
+
segment === "live" ||
|
|
1804
|
+
segment === "livez" ||
|
|
1805
|
+
segment === "ping" ||
|
|
1806
|
+
segment === "metrics" ||
|
|
1807
|
+
segment === "poll" ||
|
|
1808
|
+
segment === "polling";
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function getPathSegments(pathname) {
|
|
1813
|
+
return String(pathname || "").toLowerCase().split("/").filter(Boolean);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function findExistingEventIndex(item) {
|
|
1817
|
+
return state.events.findIndex((event) => {
|
|
1818
|
+
return event.method === item.method &&
|
|
1819
|
+
event.path === item.path &&
|
|
1820
|
+
String(event.status) === String(item.status) &&
|
|
1821
|
+
getEndpointId(event.matchedEndpoint) === getEndpointId(item.matchedEndpoint);
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function getEndpointId(endpoint) {
|
|
1826
|
+
return endpoint && endpoint.id ? endpoint.id : "";
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function findActionForRequest(startedAt) {
|
|
1830
|
+
const requestStartedAt = Number(startedAt || performance.now());
|
|
1831
|
+
return state.actions.find((action) => {
|
|
1832
|
+
const age = requestStartedAt - action.at;
|
|
1833
|
+
if (age >= -50 && age <= 3000) {
|
|
1834
|
+
return true;
|
|
1835
|
+
}
|
|
1836
|
+
return Date.now() - action.capturedAt <= 8000;
|
|
1837
|
+
}) || null;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
function normalizeUrl(value) {
|
|
1841
|
+
if (!value) {
|
|
1842
|
+
return null;
|
|
1843
|
+
}
|
|
1844
|
+
try {
|
|
1845
|
+
return new URL(String(value), window.location.href);
|
|
1846
|
+
} catch {
|
|
1847
|
+
return null;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function shouldIgnoreRequest(url) {
|
|
1852
|
+
if (
|
|
1853
|
+
url.pathname.startsWith("/_anlyx/") ||
|
|
1854
|
+
url.pathname.startsWith("/@vite/") ||
|
|
1855
|
+
url.pathname.startsWith("/_next/") ||
|
|
1856
|
+
url.pathname.startsWith("/getconfig/") ||
|
|
1857
|
+
url.pathname.includes("hot-update") ||
|
|
1858
|
+
url.pathname === "/favicon.ico"
|
|
1859
|
+
) {
|
|
1860
|
+
return true;
|
|
1861
|
+
}
|
|
1862
|
+
return /\.(css|js|map|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf)$/i.test(url.pathname);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function shouldTrackRequestUrl(value) {
|
|
1866
|
+
const normalized = normalizeUrl(value);
|
|
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
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function matchEndpoint(method, path) {
|
|
1884
|
+
const report = state.report || {};
|
|
1885
|
+
const endpoints = Array.isArray(report.endpoints) ? report.endpoints : [];
|
|
1886
|
+
const endpoint = endpoints.find((candidate) => {
|
|
1887
|
+
return String(candidate.method || "").toUpperCase() === method && endpointPathToRegex(candidate.path).test(path);
|
|
1888
|
+
});
|
|
1889
|
+
const flows = Array.isArray(report.flows) ? report.flows : [];
|
|
1890
|
+
const pages = Array.isArray(report.pages) ? report.pages : [];
|
|
1891
|
+
return {
|
|
1892
|
+
endpoint,
|
|
1893
|
+
flow: endpoint ? flows.find((flow) => flow.endpointId === endpoint.id) : null,
|
|
1894
|
+
pages: endpoint
|
|
1895
|
+
? pages.filter((page) => Array.isArray(page.apiCalls) && page.apiCalls.some((call) => call.endpointId === endpoint.id))
|
|
1896
|
+
: []
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function endpointPathToRegex(path) {
|
|
1901
|
+
const key = String(path || "");
|
|
1902
|
+
const cached = endpointRegexCache.get(key);
|
|
1903
|
+
if (cached) {
|
|
1904
|
+
return cached;
|
|
1905
|
+
}
|
|
1906
|
+
const escaped = String(path || "")
|
|
1907
|
+
.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
1908
|
+
.replace(/\\\{[^/]+\\\}/g, "[^/]+");
|
|
1909
|
+
const regex = new RegExp("^" + escaped + "$");
|
|
1910
|
+
endpointRegexCache.set(key, regex);
|
|
1911
|
+
return regex;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function render() {
|
|
1915
|
+
if (!drawer || !body) {
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
drawer.dataset.open = state.open ? "true" : "false";
|
|
1920
|
+
if (!state.open) {
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const selected = state.events.find((event) => event.id === state.selectedEventId) || null;
|
|
1925
|
+
renderReactDrawer(selected, getLatestAction());
|
|
1926
|
+
|
|
1927
|
+
installEventSelectionHandler();
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
function installEventSelectionHandler() {
|
|
1931
|
+
if (!body || body.dataset.eventSelectionBound === "true") {
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
body.dataset.eventSelectionBound = "true";
|
|
1935
|
+
const selectEventFromTarget = (target) => {
|
|
1936
|
+
const row = target && target.closest ? target.closest("[data-event-id]") : null;
|
|
1937
|
+
if (!row) {
|
|
1938
|
+
return false;
|
|
1939
|
+
}
|
|
1940
|
+
state.selectedEventId = row.getAttribute("data-event-id");
|
|
1941
|
+
state.open = true;
|
|
1942
|
+
render();
|
|
1943
|
+
return true;
|
|
1944
|
+
};
|
|
1945
|
+
body.addEventListener("click", (event) => {
|
|
1946
|
+
selectEventFromTarget(event.target);
|
|
1947
|
+
});
|
|
1948
|
+
body.addEventListener("keydown", (event) => {
|
|
1949
|
+
if (event.key !== "Enter" && event.key !== " ") {
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
if (selectEventFromTarget(event.target)) {
|
|
1953
|
+
event.preventDefault();
|
|
1954
|
+
}
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function getLatestAction() {
|
|
1959
|
+
return state.actions.find((action) => isFreshAction(action)) || null;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function getScannedHints() {
|
|
1963
|
+
const report = state.report || {};
|
|
1964
|
+
const pages = Array.isArray(report.pages) ? report.pages : [];
|
|
1965
|
+
const endpoints = Array.isArray(report.endpoints) ? report.endpoints : [];
|
|
1966
|
+
const pathname = window.location && window.location.pathname ? window.location.pathname : "/";
|
|
1967
|
+
const matchingPages = pages.filter((page) => {
|
|
1968
|
+
return page && page.route && routeToRegex(page.route).test(pathname);
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
return matchingPages.flatMap((page) => {
|
|
1972
|
+
const apiCalls = Array.isArray(page.apiCalls) ? page.apiCalls : [];
|
|
1973
|
+
return apiCalls.map((apiCall) => {
|
|
1974
|
+
const endpoint = apiCall.endpointId
|
|
1975
|
+
? endpoints.find((candidate) => candidate && candidate.id === apiCall.endpointId)
|
|
1976
|
+
: null;
|
|
1977
|
+
const method = String(apiCall.method || (endpoint && endpoint.method) || "GET").toUpperCase();
|
|
1978
|
+
const path = String(apiCall.path || (endpoint && endpoint.path) || "");
|
|
1979
|
+
return {
|
|
1980
|
+
pageRoute: page.route,
|
|
1981
|
+
pageFilePath: page.filePath,
|
|
1982
|
+
method,
|
|
1983
|
+
path,
|
|
1984
|
+
endpointId: apiCall.endpointId || (endpoint && endpoint.id),
|
|
1985
|
+
endpointLabel: endpoint ? String(endpoint.method || method).toUpperCase() + " " + endpoint.path : method + " " + path,
|
|
1986
|
+
evidence: page.captureStatus === "success" ? "capture" : "scanned-page"
|
|
1987
|
+
};
|
|
1988
|
+
});
|
|
1989
|
+
}).filter((hint) => hint.path).slice(0, 4);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function routeToRegex(route) {
|
|
1993
|
+
const escaped = String(route || "/")
|
|
1994
|
+
.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
1995
|
+
.replace(/\\\[\\\.\\\.\\\.[^\]]+\\\]/g, ".+")
|
|
1996
|
+
.replace(/\\\[\\\[\\\.\\\.\\\.[^\]]+\\\]\\\]/g, ".*")
|
|
1997
|
+
.replace(/\\\[[^\]]+\\\]/g, "[^/]+");
|
|
1998
|
+
return new RegExp("^" + escaped + "/?$");
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function renderReactDrawer(selected, latestAction) {
|
|
2002
|
+
loadOverlayUiAssets();
|
|
2003
|
+
|
|
2004
|
+
if (!window.__ANLYX_RENDER_FLOW_DRAWER__) {
|
|
2005
|
+
body.innerHTML = '<section class="anlyx-section"><h3 class="anlyx-section-title">Loading</h3><div class="anlyx-empty">Loading Anlyx Flow Drawer...</div></section>';
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
window.__ANLYX_RENDER_FLOW_DRAWER__(body, {
|
|
2010
|
+
selectedEvent: selected,
|
|
2011
|
+
events: state.events,
|
|
2012
|
+
latestAction,
|
|
2013
|
+
scannedHints: getScannedHints(),
|
|
2014
|
+
loadError: state.loadError,
|
|
2015
|
+
runtimeBaseUrl
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
})();
|
|
2019
|
+
`;
|
|
2020
|
+
}
|
|
112
2021
|
function withDefaultDependencies(dependencies) {
|
|
113
2022
|
return {
|
|
114
2023
|
loadConfig: dependencies?.loadConfig ?? loadConfig,
|
|
2024
|
+
readProjectData: dependencies?.readProjectData ?? readProjectData,
|
|
115
2025
|
readReportData: dependencies?.readReportData ?? readReportData,
|
|
116
2026
|
createLocalUiServer: dependencies?.createLocalUiServer ?? createLocalUiServer,
|
|
2027
|
+
isFrontendReachable: dependencies?.isFrontendReachable ?? isFrontendReachable,
|
|
2028
|
+
startFrontendDevServer: dependencies?.startFrontendDevServer ?? startFrontendDevServer,
|
|
117
2029
|
openBrowser: dependencies?.openBrowser ?? openBrowser
|
|
118
2030
|
};
|
|
119
2031
|
}
|