@narumitw/pi-chrome-devtools 0.51.0 → 0.52.1

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/src/runtime.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
- import type { EffectiveBrowserSettings } from "./settings.js";
2
+ import {
3
+ DEFAULT_BROWSER_HOST,
4
+ DEFAULT_BROWSER_PORT,
5
+ type EffectiveBrowserSettings,
6
+ parseConfiguredPort,
7
+ } from "./settings.js";
3
8
 
4
- export const DEFAULT_HOST = "127.0.0.1";
5
- export const DEFAULT_PORT = 9222;
9
+ export const DEFAULT_HOST = DEFAULT_BROWSER_HOST;
10
+ export const DEFAULT_PORT = DEFAULT_BROWSER_PORT;
6
11
  export const DEFAULT_TIMEOUT_MS = 10_000;
7
12
  export const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
8
13
  export const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
@@ -26,6 +31,8 @@ export interface ChromeDevToolsState {
26
31
  hostConfigured: boolean;
27
32
  portConfigured: boolean;
28
33
  autoLaunchEnabled: boolean;
34
+ endpointSource: EffectiveBrowserSettings["endpointSource"];
35
+ autoLaunchSource: EffectiveBrowserSettings["autoLaunchSource"];
29
36
  browserExecutable?: string;
30
37
  extensionPaths: string[];
31
38
  browserExecutableSource: EffectiveBrowserSettings["executablePathSource"];
@@ -71,29 +78,19 @@ export interface BrowserCandidate extends BrowserCandidateDefinition {
71
78
  resolvedExecutable: string;
72
79
  }
73
80
 
74
- export function parseConfiguredPort(value: string | undefined) {
75
- if (value === undefined) return undefined;
76
- const trimmedValue = value.trim();
77
- if (!/^\d+$/.test(trimmedValue)) return undefined;
78
- const port = Number(trimmedValue);
79
- if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
80
- return port;
81
- }
82
-
83
- const configuredHost = process.env.PI_CHROME_DEVTOOLS_HOST ?? DEFAULT_HOST;
84
- const configuredPortOverride = parseConfiguredPort(process.env.PI_CHROME_DEVTOOLS_PORT);
85
- const configuredPort = configuredPortOverride ?? DEFAULT_PORT;
81
+ export { parseConfiguredPort };
86
82
 
87
83
  export const state: ChromeDevToolsState = {
88
- host: configuredHost,
89
- port: configuredPort,
90
- configuredPort,
91
- hostConfigured: process.env.PI_CHROME_DEVTOOLS_HOST !== undefined,
92
- portConfigured: configuredPortOverride !== undefined,
93
- autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
94
- browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
84
+ host: DEFAULT_HOST,
85
+ port: DEFAULT_PORT,
86
+ configuredPort: DEFAULT_PORT,
87
+ hostConfigured: false,
88
+ portConfigured: false,
89
+ autoLaunchEnabled: true,
90
+ endpointSource: "default",
91
+ autoLaunchSource: "default",
95
92
  extensionPaths: [],
96
- browserExecutableSource: process.env.PI_CHROME_DEVTOOLS_BROWSER ? "environment" : "default",
93
+ browserExecutableSource: "default",
97
94
  extensionPathsSource: "default",
98
95
  projectSettingsTrusted: false,
99
96
  shuttingDown: false,
@@ -106,6 +103,14 @@ export function applyRuntimeBrowserSettings(
106
103
  paths: { user: string; project?: string },
107
104
  projectTrusted: boolean,
108
105
  ) {
106
+ state.host = browser.host;
107
+ state.port = browser.port;
108
+ state.configuredPort = browser.port;
109
+ state.hostConfigured = browser.hostConfigured;
110
+ state.portConfigured = browser.portConfigured;
111
+ state.autoLaunchEnabled = browser.autoLaunchEnabled;
112
+ state.endpointSource = browser.endpointSource;
113
+ state.autoLaunchSource = browser.autoLaunchSource;
109
114
  state.browserExecutable = browser.executablePath;
110
115
  state.extensionPaths = [...browser.extensionPaths];
111
116
  state.browserExecutableSource = browser.executablePathSource;
package/src/settings.ts CHANGED
@@ -23,11 +23,36 @@ export interface ChromeDevToolsSettings {
23
23
  updatedAt: number;
24
24
  }
25
25
 
26
+ export const DEFAULT_BROWSER_HOST = "127.0.0.1";
27
+ export const DEFAULT_BROWSER_PORT = 9222;
28
+ export const DEFAULT_BROWSER_ENDPOINT = `http://${DEFAULT_BROWSER_HOST}:${DEFAULT_BROWSER_PORT}`;
29
+
26
30
  export type BrowserSettingsSource = "default" | "environment" | "project" | "user";
27
31
 
32
+ export interface UserBrowserSettings {
33
+ endpoint?: string;
34
+ autoLaunch?: boolean;
35
+ executablePath?: string;
36
+ extensionPaths?: string[];
37
+ }
38
+
39
+ export interface BrowserSettingsPatch {
40
+ endpoint?: string | null;
41
+ autoLaunch?: boolean | null;
42
+ executablePath?: string | null;
43
+ }
44
+
28
45
  export interface EffectiveBrowserSettings {
46
+ endpoint: string;
47
+ host: string;
48
+ port: number;
49
+ hostConfigured: boolean;
50
+ portConfigured: boolean;
51
+ autoLaunchEnabled: boolean;
29
52
  executablePath?: string;
30
53
  extensionPaths: string[];
54
+ endpointSource: BrowserSettingsSource;
55
+ autoLaunchSource: BrowserSettingsSource;
31
56
  executablePathSource: BrowserSettingsSource;
32
57
  extensionPathsSource: BrowserSettingsSource;
33
58
  }
@@ -43,9 +68,14 @@ export interface SettingsLoadOptions {
43
68
  projectTrusted?: boolean;
44
69
  }
45
70
 
71
+ export type UserSettingsFileStatus =
72
+ | { kind: "missing" | "valid" }
73
+ | { kind: "invalid"; reason: string };
74
+
46
75
  interface SettingsLoadBase {
47
76
  effectiveBrowser: EffectiveBrowserSettings;
48
77
  paths: { user: string; project?: string };
78
+ userFile: UserSettingsFileStatus;
49
79
  warnings: string[];
50
80
  notice?: string;
51
81
  }
@@ -60,15 +90,10 @@ export interface SettingsFileOperations {
60
90
  rename(source: string, destination: string): Promise<void>;
61
91
  }
62
92
 
63
- interface NormalizedBrowserSection {
64
- executablePath?: string;
65
- extensionPaths?: string[];
66
- }
67
-
68
93
  interface NormalizedSettingsDocument {
69
94
  tools?: ChromeDevToolsToolName[];
70
95
  updatedAt?: number;
71
- browser?: NormalizedBrowserSection;
96
+ browser?: UserBrowserSettings;
72
97
  }
73
98
 
74
99
  interface SettingsDocumentResult {
@@ -86,6 +111,10 @@ const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
86
111
 
87
112
  let settingsSaveQueue = Promise.resolve();
88
113
 
114
+ export async function waitForSettingsWrites() {
115
+ await settingsSaveQueue;
116
+ }
117
+
89
118
  export async function loadSettings(options: SettingsLoadOptions = {}): Promise<SettingsLoadResult> {
90
119
  await settingsSaveQueue;
91
120
  const userPath = settingsFilePath();
@@ -127,10 +156,11 @@ export async function loadSettings(options: SettingsLoadOptions = {}): Promise<S
127
156
  warnings.push(...project.warnings);
128
157
  }
129
158
 
130
- const effectiveBrowser = resolveEffectiveBrowser(
131
- user.normalized?.browser,
132
- project.normalized?.browser,
133
- );
159
+ const environmentWarning = deprecatedEnvironmentWarning();
160
+ if (environmentWarning) warnings.push(environmentWarning);
161
+
162
+ const userBrowser = user.normalized?.browser ?? {};
163
+ const effectiveBrowser = resolveEffectiveBrowser(userBrowser, project.normalized?.browser);
134
164
  const settings = resolveSettings(user.normalized, project.normalized, effectiveBrowser);
135
165
  const recognized =
136
166
  settings.tools !== undefined ||
@@ -140,9 +170,17 @@ export async function loadSettings(options: SettingsLoadOptions = {}): Promise<S
140
170
  .filter((result) => result.kind === "invalid")
141
171
  .map((result) => result.reason)
142
172
  .filter((reason): reason is string => Boolean(reason));
173
+ const userFile: UserSettingsFileStatus =
174
+ user.kind === "invalid"
175
+ ? {
176
+ kind: "invalid",
177
+ reason: user.reason ?? `${userPath}: invalid settings`,
178
+ }
179
+ : { kind: user.kind };
143
180
  const base = {
144
181
  effectiveBrowser,
145
182
  paths,
183
+ userFile,
146
184
  warnings,
147
185
  ...(warnings.length > 0 ? { notice: warnings.join("\n") } : {}),
148
186
  };
@@ -166,28 +204,69 @@ function resolveSettings(
166
204
  }
167
205
 
168
206
  function resolveEffectiveBrowser(
169
- user: NormalizedBrowserSection | undefined,
170
- project: NormalizedBrowserSection | undefined,
207
+ user: UserBrowserSettings,
208
+ project: UserBrowserSettings | undefined,
171
209
  ): EffectiveBrowserSettings {
210
+ const configuredEndpoint = parseBrowserEndpoint(user.endpoint ?? DEFAULT_BROWSER_ENDPOINT);
211
+ const environmentHost = process.env.PI_CHROME_DEVTOOLS_HOST;
212
+ const environmentPortValue = process.env.PI_CHROME_DEVTOOLS_PORT;
213
+ const environmentPort = parseConfiguredPort(environmentPortValue);
214
+ const environmentEndpointConfigured =
215
+ environmentHost !== undefined || environmentPort !== undefined;
216
+ const host = environmentHost ?? configuredEndpoint.host;
217
+ const port = environmentPort ?? configuredEndpoint.port;
218
+ const environmentAutoLaunch = process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH;
172
219
  const environmentExecutable = process.env.PI_CHROME_DEVTOOLS_BROWSER;
173
- const executablePath = environmentExecutable || user?.executablePath;
174
- const extensionPaths = project?.extensionPaths ?? user?.extensionPaths ?? [];
220
+ const executablePath = environmentExecutable || user.executablePath;
221
+ const extensionPaths = project?.extensionPaths ?? user.extensionPaths ?? [];
222
+ const endpointSource: BrowserSettingsSource = environmentEndpointConfigured
223
+ ? "environment"
224
+ : user.endpoint
225
+ ? "user"
226
+ : "default";
175
227
  return {
228
+ endpoint: formatBrowserEndpoint(host, port),
229
+ host,
230
+ port,
231
+ hostConfigured: environmentHost !== undefined || user.endpoint !== undefined,
232
+ portConfigured: environmentPort !== undefined || user.endpoint !== undefined,
233
+ autoLaunchEnabled:
234
+ environmentAutoLaunch === undefined
235
+ ? (user.autoLaunch ?? true)
236
+ : environmentAutoLaunch !== "0",
176
237
  ...(executablePath ? { executablePath } : {}),
177
238
  extensionPaths: [...extensionPaths],
239
+ endpointSource,
240
+ autoLaunchSource:
241
+ environmentAutoLaunch !== undefined
242
+ ? "environment"
243
+ : user.autoLaunch !== undefined
244
+ ? "user"
245
+ : "default",
178
246
  executablePathSource: environmentExecutable
179
247
  ? "environment"
180
- : user?.executablePath
248
+ : user.executablePath
181
249
  ? "user"
182
250
  : "default",
183
251
  extensionPathsSource: project?.extensionPaths
184
252
  ? "project"
185
- : user?.extensionPaths
253
+ : user.extensionPaths
186
254
  ? "user"
187
255
  : "default",
188
256
  };
189
257
  }
190
258
 
259
+ function deprecatedEnvironmentWarning() {
260
+ const configuredNames = [
261
+ "PI_CHROME_DEVTOOLS_HOST",
262
+ "PI_CHROME_DEVTOOLS_PORT",
263
+ "PI_CHROME_DEVTOOLS_AUTO_LAUNCH",
264
+ "PI_CHROME_DEVTOOLS_BROWSER",
265
+ ].filter((name) => process.env[name] !== undefined);
266
+ if (configuredNames.length === 0) return undefined;
267
+ return `Chrome DevTools environment settings are deprecated and will be removed in a future version: ${configuredNames.join(", ")}. Move them to browser.endpoint, browser.autoLaunch, and browser.executablePath in ${settingsFilePath()}. Environment values remain active overrides during the deprecation period.`;
268
+ }
269
+
191
270
  async function readSettingsDocument(
192
271
  filePath: string,
193
272
  scope: "project" | "user",
@@ -214,7 +293,7 @@ async function readSettingsDocument(
214
293
  return { kind: "invalid", reason, warnings: [`Chrome DevTools settings ignored: ${reason}`] };
215
294
  }
216
295
 
217
- const scopeWarnings = projectExecutableWarnings(parsed, scope, filePath);
296
+ const scopeWarnings = projectMachineSettingsWarnings(parsed, scope, filePath);
218
297
  try {
219
298
  const normalized = await normalizeSettingsDocument(parsed, scope, filePath, cwd);
220
299
  return { kind: "valid", document: { ...parsed }, normalized, warnings: scopeWarnings };
@@ -228,21 +307,19 @@ async function readSettingsDocument(
228
307
  }
229
308
  }
230
309
 
231
- function projectExecutableWarnings(
310
+ function projectMachineSettingsWarnings(
232
311
  document: Record<string, unknown>,
233
312
  scope: "project" | "user",
234
313
  filePath: string,
235
314
  ) {
236
- if (
237
- scope !== "project" ||
238
- !isRecord(document.browser) ||
239
- document.browser.executablePath === undefined
240
- ) {
241
- return [];
242
- }
243
- return [
244
- `Chrome DevTools project browser.executablePath ignored in ${filePath}; configure the machine-owned executable in ${settingsFilePath()}.`,
245
- ];
315
+ if (scope !== "project" || !isRecord(document.browser)) return [];
316
+ const browser = document.browser;
317
+ return ["endpoint", "autoLaunch", "executablePath"]
318
+ .filter((field) => browser[field] !== undefined)
319
+ .map(
320
+ (field) =>
321
+ `Chrome DevTools project browser.${field} ignored in ${filePath}; configure this machine-owned setting in ${settingsFilePath()}.`,
322
+ );
246
323
  }
247
324
 
248
325
  async function normalizeSettingsDocument(
@@ -277,8 +354,20 @@ async function normalizeBrowserSection(
277
354
  scope: "project" | "user",
278
355
  _filePath: string,
279
356
  cwd?: string,
280
- ): Promise<NormalizedBrowserSection> {
281
- const normalized: NormalizedBrowserSection = {};
357
+ ): Promise<UserBrowserSettings> {
358
+ const normalized: UserBrowserSettings = {};
359
+ if (scope === "user" && browser.endpoint !== undefined) {
360
+ if (typeof browser.endpoint !== "string") {
361
+ throw new Error("expected browser.endpoint to be an HTTP URL with an explicit port");
362
+ }
363
+ normalized.endpoint = parseBrowserEndpoint(browser.endpoint).endpoint;
364
+ }
365
+ if (scope === "user" && browser.autoLaunch !== undefined) {
366
+ if (typeof browser.autoLaunch !== "boolean") {
367
+ throw new Error("expected browser.autoLaunch to be a boolean");
368
+ }
369
+ normalized.autoLaunch = browser.autoLaunch;
370
+ }
282
371
  if (scope === "user" && browser.executablePath !== undefined) {
283
372
  if (typeof browser.executablePath !== "string" || browser.executablePath.length === 0) {
284
373
  throw new Error("expected browser.executablePath to be a non-empty absolute path");
@@ -350,6 +439,52 @@ async function validateUnpackedExtensionPath(extensionPath: string) {
350
439
  return canonicalPath;
351
440
  }
352
441
 
442
+ export function parseBrowserEndpoint(value: string) {
443
+ const trimmedValue = value.trim();
444
+ const explicitPort = trimmedValue.match(/^http:\/\/(?:\[[^\]]+\]|[^/:?#]+):(\d+)\/?$/i)?.[1];
445
+ let endpoint: URL;
446
+ try {
447
+ endpoint = new URL(trimmedValue);
448
+ } catch {
449
+ throw new Error("browser.endpoint must be a valid HTTP URL with an explicit port");
450
+ }
451
+ if (
452
+ endpoint.protocol !== "http:" ||
453
+ endpoint.username ||
454
+ endpoint.password ||
455
+ !endpoint.hostname ||
456
+ !explicitPort ||
457
+ (endpoint.pathname !== "/" && endpoint.pathname !== "") ||
458
+ endpoint.search ||
459
+ endpoint.hash
460
+ ) {
461
+ throw new Error(
462
+ "browser.endpoint must be an HTTP origin with an explicit port and no credentials, path, query, or fragment",
463
+ );
464
+ }
465
+ const port = parseConfiguredPort(explicitPort);
466
+ if (port === undefined) {
467
+ throw new Error("browser.endpoint must use a port from 1 through 65535");
468
+ }
469
+ const host = endpoint.hostname;
470
+ return { endpoint: formatBrowserEndpoint(host, port), host, port };
471
+ }
472
+
473
+ export function parseConfiguredPort(value: string | undefined) {
474
+ if (value === undefined) return undefined;
475
+ const trimmedValue = value.trim();
476
+ if (!/^\d+$/.test(trimmedValue)) return undefined;
477
+ const port = Number(trimmedValue);
478
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
479
+ return port;
480
+ }
481
+
482
+ function formatBrowserEndpoint(host: string, port: number) {
483
+ const formattedHost =
484
+ host.startsWith("[") && host.endsWith("]") ? host : host.includes(":") ? `[${host}]` : host;
485
+ return `http://${formattedHost}:${port}`;
486
+ }
487
+
353
488
  export function normalizeChromeDevtoolsSettings(
354
489
  value: unknown,
355
490
  ): ChromeDevToolsSettings | undefined {
@@ -381,13 +516,48 @@ export function saveSettings(
381
516
  settings: ChromeDevToolsSettings,
382
517
  operations: Partial<SettingsFileOperations> = {},
383
518
  ): Promise<void> {
384
- const operation = settingsSaveQueue.then(() => saveSettingsNow(settings, operations));
519
+ return queueSettingsMutation(
520
+ (current) => ({
521
+ ...current,
522
+ tools: [...settings.tools],
523
+ updatedAt: settings.updatedAt,
524
+ }),
525
+ operations,
526
+ );
527
+ }
528
+
529
+ export function saveBrowserSettings(
530
+ patch: BrowserSettingsPatch,
531
+ operations: Partial<SettingsFileOperations> = {},
532
+ ): Promise<void> {
533
+ return queueSettingsMutation(async (current) => {
534
+ const browser = isRecord(current.browser) ? { ...current.browser } : {};
535
+ for (const field of ["endpoint", "autoLaunch", "executablePath"] as const) {
536
+ const value = patch[field];
537
+ if (value === undefined) continue;
538
+ if (value === null) delete browser[field];
539
+ else browser[field] = value;
540
+ }
541
+ await normalizeBrowserSection(browser, "user", settingsFilePath());
542
+ return { ...current, browser };
543
+ }, operations);
544
+ }
545
+
546
+ function queueSettingsMutation(
547
+ mutate: (
548
+ current: Record<string, unknown>,
549
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>,
550
+ operations: Partial<SettingsFileOperations>,
551
+ ) {
552
+ const operation = settingsSaveQueue.then(() => saveSettingsMutationNow(mutate, operations));
385
553
  settingsSaveQueue = operation.catch(() => undefined);
386
554
  return operation;
387
555
  }
388
556
 
389
- async function saveSettingsNow(
390
- settings: ChromeDevToolsSettings,
557
+ async function saveSettingsMutationNow(
558
+ mutate: (
559
+ current: Record<string, unknown>,
560
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>,
391
561
  operations: Partial<SettingsFileOperations>,
392
562
  ): Promise<void> {
393
563
  const filePath = settingsFilePath();
@@ -397,11 +567,7 @@ async function saveSettingsNow(
397
567
  if (current.kind === "invalid") {
398
568
  throw new Error(`Cannot save Chrome DevTools settings until you repair ${current.reason}`);
399
569
  }
400
- const nextDocument = {
401
- ...(current.document ?? {}),
402
- tools: [...settings.tools],
403
- updatedAt: settings.updatedAt,
404
- };
570
+ const nextDocument = await mutate(current.document ?? {});
405
571
  await mkdir(dirname(filePath), { recursive: true });
406
572
  const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
407
573
  try {
@@ -268,10 +268,11 @@ function browserSettingsStatusLines() {
268
268
  `Project settings: ${state.projectSettingsFilePath} (${state.projectSettingsTrusted ? "trusted" : "untrusted; ignored"})`,
269
269
  ]
270
270
  : []),
271
+ `Auto-launch: ${state.autoLaunchEnabled ? "on" : "off"} (${state.autoLaunchSource})`,
271
272
  `Browser executable: ${state.browserExecutable ?? "automatic discovery"} (${state.browserExecutableSource})`,
272
273
  `Unpacked extensions (${state.extensionPathsSource}):`,
273
274
  ...extensionLines,
274
- "Settings changes apply to a new managed browser after /reload or session replacement.",
275
+ "Confirmed menu settings apply before the next browser connection; manual JSON edits require /reload or session replacement.",
275
276
  ...(state.extensionPaths.length > 0
276
277
  ? [
277
278
  "Unpacked extensions require Chrome for Testing or Chromium and execute trusted browser code.",
@@ -287,6 +288,7 @@ export function buildCommandGuide() {
287
288
  "/chrome-devtools help — show command usage",
288
289
  "/chrome-devtools quickstart — show endpoint and launch help",
289
290
  "/chrome-devtools status — show tool and settings status",
291
+ "/chrome-devtools settings — edit browser connection settings",
290
292
  "/chrome-devtools tools — choose tools available to lazy-load",
291
293
  "/chrome-devtools toggle|select — compatibility aliases for tools",
292
294
  "/chrome-devtools enable|on — make all Chrome DevTools tools available to lazy-load",