botprint 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PRIVACY.md +94 -0
- package/README.md +221 -0
- package/docs/agent-usage.md +109 -0
- package/package.json +56 -0
- package/src/cdp.ts +267 -0
- package/src/cli.ts +758 -0
- package/src/coerce.ts +19 -0
- package/src/compare.ts +363 -0
- package/src/errors.ts +15 -0
- package/src/exit-codes.ts +10 -0
- package/src/findings.ts +342 -0
- package/src/guide.ts +152 -0
- package/src/probe.ts +700 -0
- package/src/profile.ts +188 -0
- package/src/render.ts +268 -0
- package/src/run.ts +432 -0
- package/src/user-agent.ts +50 -0
- package/src/version.ts +1 -0
package/src/run.ts
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { discoverWebSocketUrl, connectCdp, getBrowserVersion, getTargets, type CdpClient, type CdpTarget } from "./cdp";
|
|
2
|
+
import { EXIT_CODES } from "./exit-codes";
|
|
3
|
+
import { BotprintError, errorDetail } from "./errors";
|
|
4
|
+
import { analyzeProfile } from "./findings";
|
|
5
|
+
import { endpointKind, SCHEMA_VERSION, type BotprintProfile, type ProbeWarning, writeProfile } from "./profile";
|
|
6
|
+
import { DEFAULT_PROBE_TIMEOUT_MS, runDiagnosticProbe, runExistingTargetProbe, type ProbeRunResult } from "./probe";
|
|
7
|
+
import { BOTPRINT_VERSION } from "./version";
|
|
8
|
+
|
|
9
|
+
export type RunOptions = {
|
|
10
|
+
cdp: string;
|
|
11
|
+
out: string;
|
|
12
|
+
label: string | undefined;
|
|
13
|
+
timeout: number | undefined;
|
|
14
|
+
probeTimeout: number | undefined;
|
|
15
|
+
authHeaders: string[];
|
|
16
|
+
diagnosticBind: string | undefined;
|
|
17
|
+
diagnosticHost: string | undefined;
|
|
18
|
+
context: string | undefined;
|
|
19
|
+
target: string | undefined;
|
|
20
|
+
targetUrl: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TargetListOptions = {
|
|
24
|
+
cdp: string;
|
|
25
|
+
timeout: number | undefined;
|
|
26
|
+
authHeaders: string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
30
|
+
const DEFAULT_DIAGNOSTIC_BIND = "127.0.0.1";
|
|
31
|
+
|
|
32
|
+
// Request headers recorded from the browser's fetch of the diagnostic page. Allowlisted so
|
|
33
|
+
// profiles never carry cookies, authorization, or proxy headers.
|
|
34
|
+
export const CAPTURED_REQUEST_HEADERS = [
|
|
35
|
+
"accept",
|
|
36
|
+
"accept-encoding",
|
|
37
|
+
"accept-language",
|
|
38
|
+
"dnt",
|
|
39
|
+
"priority",
|
|
40
|
+
"sec-ch-ua",
|
|
41
|
+
"sec-ch-ua-mobile",
|
|
42
|
+
"sec-ch-ua-platform",
|
|
43
|
+
"sec-fetch-dest",
|
|
44
|
+
"sec-fetch-mode",
|
|
45
|
+
"sec-fetch-site",
|
|
46
|
+
"sec-fetch-user",
|
|
47
|
+
"sec-gpc",
|
|
48
|
+
"upgrade-insecure-requests",
|
|
49
|
+
"user-agent",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
export async function listTargets(options: TargetListOptions): Promise<CdpTarget[]> {
|
|
53
|
+
const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
54
|
+
const headers = parseHeaderLines(options.authHeaders);
|
|
55
|
+
const webSocketUrl = await discoverWebSocketUrl(options.cdp, headers);
|
|
56
|
+
const client = await connectCdp(webSocketUrl, headers, timeoutMs);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return (await getTargets(client)).filter((target) => target.type === "page");
|
|
60
|
+
} finally {
|
|
61
|
+
client.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runProfile(options: RunOptions): Promise<BotprintProfile> {
|
|
66
|
+
const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
67
|
+
const headers = parseHeaderLines(options.authHeaders);
|
|
68
|
+
const webSocketUrl = await discoverWebSocketUrl(options.cdp, headers);
|
|
69
|
+
const client = await connectCdp(webSocketUrl, headers, timeoutMs);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
if (options.target !== undefined || options.targetUrl !== undefined) {
|
|
73
|
+
return await runExistingTargetProfile(client, options);
|
|
74
|
+
}
|
|
75
|
+
return await runFreshPageProfile(client, options);
|
|
76
|
+
} finally {
|
|
77
|
+
client.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runFreshPageProfile(client: CdpClient, options: RunOptions): Promise<BotprintProfile> {
|
|
82
|
+
const probeTimeoutMs = options.probeTimeout ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
83
|
+
const server = startDiagnosticServer(options.diagnosticBind ?? DEFAULT_DIAGNOSTIC_BIND);
|
|
84
|
+
try {
|
|
85
|
+
const diagnosticUrl = browserDiagnosticUrl(server.url, options.diagnosticHost);
|
|
86
|
+
const browserVersion = await getBrowserVersion(client);
|
|
87
|
+
const context = selectBrowserContext(await getTargets(client), options.context);
|
|
88
|
+
let probe: ProbeRunResult;
|
|
89
|
+
try {
|
|
90
|
+
probe = await runDiagnosticProbe(client, browserVersion, diagnosticUrl, {
|
|
91
|
+
timeoutMs: probeTimeoutMs,
|
|
92
|
+
browserContextId: context.browserContextId,
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw rejectedContextError(error, context) ?? error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const captured = server.capturedHeaders();
|
|
99
|
+
const warnings: ProbeWarning[] = [...context.warnings];
|
|
100
|
+
if (captured === undefined) {
|
|
101
|
+
warnings.push("http-not-captured");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const profile = buildProfile(options, browserVersion, probe, {
|
|
105
|
+
probe_context: "local-diagnostic-page",
|
|
106
|
+
target_url: probe.targetUrl,
|
|
107
|
+
browser_context_id: context.browserContextId,
|
|
108
|
+
browser_context_source: context.source,
|
|
109
|
+
probe_timeout_ms: probeTimeoutMs,
|
|
110
|
+
probe_warnings: warnings.length > 0 ? warnings : undefined,
|
|
111
|
+
});
|
|
112
|
+
profile.signals.http = captured === undefined ? { status: "not-captured", request_headers: {} } : { status: "captured", request_headers: captured };
|
|
113
|
+
|
|
114
|
+
return await finalizeProfile(options, profile);
|
|
115
|
+
} finally {
|
|
116
|
+
server.stop(true);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runExistingTargetProfile(client: CdpClient, options: RunOptions): Promise<BotprintProfile> {
|
|
121
|
+
const probeTimeoutMs = options.probeTimeout ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
122
|
+
const targets = await getTargets(client);
|
|
123
|
+
const target = selectTarget(targets, options);
|
|
124
|
+
const browserVersion = await getBrowserVersion(client);
|
|
125
|
+
const probe = await runExistingTargetProbe(client, browserVersion, target.targetId, { timeoutMs: probeTimeoutMs });
|
|
126
|
+
const profile = buildProfile(options, browserVersion, probe, {
|
|
127
|
+
probe_context: "existing-target",
|
|
128
|
+
target_url: probe.targetUrl || target.url,
|
|
129
|
+
browser_context_id: target.browserContextId,
|
|
130
|
+
target_id: target.targetId,
|
|
131
|
+
target_type: target.type,
|
|
132
|
+
probe_timeout_ms: probeTimeoutMs,
|
|
133
|
+
probe_warnings: ["page-contamination-possible"],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return finalizeProfile(options, profile);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function finalizeProfile(options: RunOptions, profile: BotprintProfile): Promise<BotprintProfile> {
|
|
140
|
+
if (options.label !== undefined) {
|
|
141
|
+
profile.source.label = options.label;
|
|
142
|
+
}
|
|
143
|
+
profile.findings = analyzeProfile(profile);
|
|
144
|
+
await writeProfile(options.out, profile);
|
|
145
|
+
return profile;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type BrowserContextSelection = {
|
|
149
|
+
browserContextId: string | undefined;
|
|
150
|
+
source: NonNullable<BotprintProfile["source"]["browser_context_source"]>;
|
|
151
|
+
warnings: ProbeWarning[];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Fresh diagnostic pages are created inside the browser context of an existing page target so
|
|
155
|
+
// that context-level configuration (Playwright/Puppeteer locale, timezone, viewport, permissions,
|
|
156
|
+
// init scripts) is measured. `--context default` forces the browser's default context; `--context
|
|
157
|
+
// <id>` names one explicitly.
|
|
158
|
+
export function selectBrowserContext(targets: CdpTarget[], requested: string | undefined): BrowserContextSelection {
|
|
159
|
+
if (requested === "default") {
|
|
160
|
+
return { browserContextId: undefined, source: "browser-default", warnings: [] };
|
|
161
|
+
}
|
|
162
|
+
if (requested !== undefined) {
|
|
163
|
+
return { browserContextId: requested, source: "explicit", warnings: [] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pages = targets.filter((target) => target.type === "page" && target.browserContextId !== undefined);
|
|
167
|
+
const first = pages[0];
|
|
168
|
+
if (first === undefined || first.browserContextId === undefined) {
|
|
169
|
+
return { browserContextId: undefined, source: "browser-default", warnings: ["default-browser-context"] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const contexts = new Set(pages.map((target) => target.browserContextId));
|
|
173
|
+
return {
|
|
174
|
+
browserContextId: first.browserContextId,
|
|
175
|
+
source: "page-target",
|
|
176
|
+
warnings: contexts.size > 1 ? ["multiple-browser-contexts"] : [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Chromium answers Target.createTarget with "Failed to find browser context with id ..." when an
|
|
181
|
+
// explicit --context id is unknown; surface that as a usage error instead of a CDP failure.
|
|
182
|
+
export function rejectedContextError(error: unknown, context: BrowserContextSelection): BotprintError | undefined {
|
|
183
|
+
if (context.source !== "explicit" || context.browserContextId === undefined) return undefined;
|
|
184
|
+
const detail = errorDetail(error);
|
|
185
|
+
if (!/browser context/i.test(detail)) return undefined;
|
|
186
|
+
return new BotprintError(`--context ${context.browserContextId} was rejected by the browser: ${detail}. List context IDs with \`botprint targets --cdp <url> --json\`.`, EXIT_CODES.usage);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildProfile(
|
|
190
|
+
options: RunOptions,
|
|
191
|
+
browserVersion: Awaited<ReturnType<typeof getBrowserVersion>>,
|
|
192
|
+
probe: ProbeRunResult,
|
|
193
|
+
source: Pick<BotprintProfile["source"], "probe_context" | "target_url"> &
|
|
194
|
+
Partial<Pick<BotprintProfile["source"], "browser_context_id" | "browser_context_source" | "target_id" | "target_type" | "probe_timeout_ms" | "probe_warnings">>,
|
|
195
|
+
): BotprintProfile {
|
|
196
|
+
const result = probe.result;
|
|
197
|
+
return {
|
|
198
|
+
schema_version: SCHEMA_VERSION,
|
|
199
|
+
botprint_version: BOTPRINT_VERSION,
|
|
200
|
+
created_at: new Date().toISOString(),
|
|
201
|
+
source: {
|
|
202
|
+
cdp_endpoint_kind: endpointKind(options.cdp),
|
|
203
|
+
browser_version: browserVersion.product,
|
|
204
|
+
...source,
|
|
205
|
+
},
|
|
206
|
+
signals: {
|
|
207
|
+
identity: {
|
|
208
|
+
user_agent: result.userAgent,
|
|
209
|
+
user_agent_data: result.userAgentData ?? undefined,
|
|
210
|
+
platform: result.platform,
|
|
211
|
+
language: result.language,
|
|
212
|
+
languages: result.languages,
|
|
213
|
+
plugins_count: result.pluginsCount,
|
|
214
|
+
mime_types_count: result.mimeTypesCount,
|
|
215
|
+
pdf_viewer_enabled: result.pdfViewerEnabled,
|
|
216
|
+
},
|
|
217
|
+
automation: {
|
|
218
|
+
webdriver: result.webdriver,
|
|
219
|
+
chrome_object: result.chromeObject,
|
|
220
|
+
globals: result.automationGlobals,
|
|
221
|
+
},
|
|
222
|
+
environment: {
|
|
223
|
+
timezone: result.timezone,
|
|
224
|
+
viewport: {
|
|
225
|
+
width: result.viewport.width,
|
|
226
|
+
height: result.viewport.height,
|
|
227
|
+
device_scale_factor: result.viewport.deviceScaleFactor,
|
|
228
|
+
},
|
|
229
|
+
outer_window: {
|
|
230
|
+
width: result.outerWindow.width,
|
|
231
|
+
height: result.outerWindow.height,
|
|
232
|
+
},
|
|
233
|
+
screen: {
|
|
234
|
+
width: result.screen.width,
|
|
235
|
+
height: result.screen.height,
|
|
236
|
+
avail_width: result.screen.availWidth,
|
|
237
|
+
avail_height: result.screen.availHeight,
|
|
238
|
+
color_depth: result.screen.colorDepth,
|
|
239
|
+
},
|
|
240
|
+
hardware_concurrency: result.hardwareConcurrency,
|
|
241
|
+
device_memory: result.deviceMemory,
|
|
242
|
+
speech_voices_count: result.speechVoicesCount,
|
|
243
|
+
},
|
|
244
|
+
rendering: {
|
|
245
|
+
webgl: {
|
|
246
|
+
status: availability(result.rendering.webgl.status),
|
|
247
|
+
vendor: optionalString(result.rendering.webgl.vendor),
|
|
248
|
+
renderer: optionalString(result.rendering.webgl.renderer),
|
|
249
|
+
unmasked_vendor: optionalString(result.rendering.webgl.unmaskedVendor),
|
|
250
|
+
unmasked_renderer: optionalString(result.rendering.webgl.unmaskedRenderer),
|
|
251
|
+
},
|
|
252
|
+
canvas: {
|
|
253
|
+
status: canvasStatus(result.rendering.canvas.status),
|
|
254
|
+
hash: optionalString(result.rendering.canvas.hash),
|
|
255
|
+
},
|
|
256
|
+
audio: {
|
|
257
|
+
status: timedAvailability(result.rendering.audio.status),
|
|
258
|
+
hash: optionalString(result.rendering.audio.hash),
|
|
259
|
+
},
|
|
260
|
+
fonts: {
|
|
261
|
+
status: availability(result.rendering.fonts.status),
|
|
262
|
+
checked: result.rendering.fonts.checked,
|
|
263
|
+
detected: [...result.rendering.fonts.detected].sort(),
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
storage: {
|
|
267
|
+
cookies_enabled: result.cookiesEnabled,
|
|
268
|
+
local_storage: availability(result.storage.localStorage),
|
|
269
|
+
session_storage: availability(result.storage.sessionStorage),
|
|
270
|
+
indexed_db: timedAvailability(result.storage.indexedDb),
|
|
271
|
+
service_worker: availability(result.storage.serviceWorker),
|
|
272
|
+
},
|
|
273
|
+
permissions: normalizePermissions(result.permissions),
|
|
274
|
+
webrtc: {
|
|
275
|
+
api_available: result.webrtc.apiAvailable,
|
|
276
|
+
host_candidate_gathering: webrtcStatus(result.webrtc.hostCandidateGathering),
|
|
277
|
+
candidate_count: result.webrtc.candidateCount,
|
|
278
|
+
},
|
|
279
|
+
cdp: {
|
|
280
|
+
product: browserVersion.product,
|
|
281
|
+
protocol_version: browserVersion.protocolVersion,
|
|
282
|
+
user_agent: browserVersion.userAgent,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
findings: [],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function selectTarget(targets: CdpTarget[], options: Pick<RunOptions, "target" | "targetUrl">): CdpTarget {
|
|
290
|
+
if (options.target !== undefined) {
|
|
291
|
+
const target = targets.find((candidate) => candidate.targetId === options.target);
|
|
292
|
+
if (target === undefined) {
|
|
293
|
+
throw new BotprintError(`No CDP page target found with targetId ${options.target}.`, EXIT_CODES.usage);
|
|
294
|
+
}
|
|
295
|
+
if (target.type !== "page") {
|
|
296
|
+
throw new BotprintError(`Target ${options.target} has type ${target.type}; only page targets are supported.`, EXIT_CODES.usage);
|
|
297
|
+
}
|
|
298
|
+
return target;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const targetUrl = options.targetUrl;
|
|
302
|
+
if (targetUrl === undefined) {
|
|
303
|
+
throw new BotprintError("Existing-target run requires --target or --target-url.", EXIT_CODES.usage);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const matches = targets.filter((target) => target.type === "page" && target.url.startsWith(targetUrl));
|
|
307
|
+
if (matches.length === 1) {
|
|
308
|
+
const match = matches[0];
|
|
309
|
+
if (match !== undefined) {
|
|
310
|
+
return match;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const matchList = matches.length === 0 ? "no matches" : matches.map((target) => `${target.targetId} ${target.url}`).join("; ");
|
|
315
|
+
throw new BotprintError(`--target-url ${targetUrl} must resolve to exactly one page target; found ${matches.length}: ${matchList}`, EXIT_CODES.usage);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function parseHeaderLines(lines: string[]): Record<string, string> {
|
|
319
|
+
const headers: Record<string, string> = {};
|
|
320
|
+
|
|
321
|
+
for (const line of lines) {
|
|
322
|
+
const separator = line.indexOf(":");
|
|
323
|
+
if (separator <= 0) {
|
|
324
|
+
throw new BotprintError(`Invalid --auth-header value: ${line}`, EXIT_CODES.usage);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const name = line.slice(0, separator).trim();
|
|
328
|
+
const value = line.slice(separator + 1).trim();
|
|
329
|
+
if (name.length === 0 || value.length === 0) {
|
|
330
|
+
throw new BotprintError(`Invalid --auth-header value: ${line}`, EXIT_CODES.usage);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
headers[name] = value;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return headers;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function captureRequestHeaders(headers: Headers): Record<string, string> {
|
|
340
|
+
const out: Record<string, string> = {};
|
|
341
|
+
for (const name of CAPTURED_REQUEST_HEADERS) {
|
|
342
|
+
const value = headers.get(name);
|
|
343
|
+
if (value !== null) {
|
|
344
|
+
out[name] = value;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function optionalString(value: unknown): string | undefined {
|
|
351
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function availability(value: unknown): "available" | "unavailable" | "error" {
|
|
355
|
+
return value === "available" || value === "unavailable" || value === "error" ? value : "error";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function timedAvailability(value: unknown): "available" | "unavailable" | "error" | "timeout" {
|
|
359
|
+
return value === "available" || value === "unavailable" || value === "error" || value === "timeout" ? value : "error";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function canvasStatus(value: unknown): "stable" | "unstable" | "unavailable" | "error" {
|
|
363
|
+
return value === "stable" || value === "unstable" || value === "unavailable" || value === "error" ? value : "error";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function webrtcStatus(value: unknown): "present" | "absent" | "unsupported" | "error" | "timeout" {
|
|
367
|
+
return value === "present" || value === "absent" || value === "unsupported" || value === "error" || value === "timeout" ? value : "error";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
type PermissionState = BotprintProfile["signals"]["permissions"][string];
|
|
371
|
+
|
|
372
|
+
function normalizePermissions(values: Record<string, string>): Record<string, PermissionState> {
|
|
373
|
+
const out: Record<string, PermissionState> = {};
|
|
374
|
+
|
|
375
|
+
for (const [key, value] of Object.entries(values)) {
|
|
376
|
+
if (value === "granted" || value === "denied" || value === "prompt" || value === "unsupported" || value === "error") {
|
|
377
|
+
out[key] = value;
|
|
378
|
+
} else {
|
|
379
|
+
out[key] = "unknown";
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function browserDiagnosticUrl(serverUrl: URL, diagnosticHost: string | undefined): string {
|
|
387
|
+
if (diagnosticHost === undefined) {
|
|
388
|
+
return serverUrl.href;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const url = new URL(serverUrl.href);
|
|
392
|
+
url.hostname = diagnosticHost;
|
|
393
|
+
return url.href;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
type DiagnosticServer = {
|
|
397
|
+
url: URL;
|
|
398
|
+
stop(force?: boolean): void;
|
|
399
|
+
capturedHeaders(): Record<string, string> | undefined;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
function startDiagnosticServer(hostname: string): DiagnosticServer {
|
|
403
|
+
let captured: Record<string, string> | undefined;
|
|
404
|
+
try {
|
|
405
|
+
const server = Bun.serve({
|
|
406
|
+
hostname,
|
|
407
|
+
port: 0,
|
|
408
|
+
fetch(request) {
|
|
409
|
+
const url = new URL(request.url);
|
|
410
|
+
// Record the top-level document request only; favicon and other follow-up requests
|
|
411
|
+
// carry different Accept/Sec-Fetch headers.
|
|
412
|
+
if (url.pathname === "/" && captured === undefined) {
|
|
413
|
+
captured = captureRequestHeaders(request.headers);
|
|
414
|
+
}
|
|
415
|
+
return new Response("<!doctype html><title>Botprint diagnostic page</title>", {
|
|
416
|
+
headers: {
|
|
417
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
418
|
+
"Cache-Control": "no-store",
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
return {
|
|
424
|
+
url: server.url,
|
|
425
|
+
stop: (force) => server.stop(force),
|
|
426
|
+
capturedHeaders: () => captured,
|
|
427
|
+
};
|
|
428
|
+
} catch (error) {
|
|
429
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
430
|
+
throw new BotprintError(`Failed to bind diagnostic server to ${hostname}: ${detail}`, EXIT_CODES.usage);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type ParsedUserAgent = {
|
|
2
|
+
family: string;
|
|
3
|
+
major: number | undefined;
|
|
4
|
+
headless: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const FAMILY_PATTERNS: Array<[string, RegExp]> = [
|
|
8
|
+
["HeadlessChrome", /HeadlessChrome\/(\d+)/],
|
|
9
|
+
["Edge", /Edg(?:e|A|iOS)?\/(\d+)/],
|
|
10
|
+
["Chrome", /(?:Chrome|CriOS)\/(\d+)/],
|
|
11
|
+
["Firefox", /Firefox\/(\d+)/],
|
|
12
|
+
["Safari", /Version\/(\d+)[^ ]* Safari\//],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Parses the browser family and major version out of a user agent string so comparisons can
|
|
16
|
+
// ignore build-number noise (Chrome's reduced UA reports 0.0.0 for minor versions while the
|
|
17
|
+
// headless shell reports the full build).
|
|
18
|
+
export function parseUserAgent(userAgent: string): ParsedUserAgent {
|
|
19
|
+
for (const [family, pattern] of FAMILY_PATTERNS) {
|
|
20
|
+
const match = pattern.exec(userAgent);
|
|
21
|
+
if (match !== null) {
|
|
22
|
+
const major = Number.parseInt(match[1] ?? "", 10);
|
|
23
|
+
return { family, major: Number.isSafeInteger(major) ? major : undefined, headless: family === "HeadlessChrome" };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { family: userAgent.length === 0 ? "unknown" : "other", major: undefined, headless: /headless/i.test(userAgent) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ParsedProduct = {
|
|
30
|
+
family: string;
|
|
31
|
+
binaryFamily: string;
|
|
32
|
+
major: number | undefined;
|
|
33
|
+
headless: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Parses a CDP Browser.getVersion product string such as "HeadlessChrome/145.0.0.0".
|
|
37
|
+
export function parseBrowserProduct(product: string): ParsedProduct {
|
|
38
|
+
const parts = product.split("/", 2);
|
|
39
|
+
const family = parts[0] ?? product;
|
|
40
|
+
const version = parts[1];
|
|
41
|
+
const majorText = version?.split(".", 1)[0];
|
|
42
|
+
const major = majorText === undefined ? undefined : Number.parseInt(majorText, 10);
|
|
43
|
+
const headless = /^Headless/i.test(family);
|
|
44
|
+
return {
|
|
45
|
+
family,
|
|
46
|
+
binaryFamily: family.replace(/^Headless/i, ""),
|
|
47
|
+
major: Number.isSafeInteger(major) ? major : undefined,
|
|
48
|
+
headless,
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const BOTPRINT_VERSION = "0.2.0";
|