chrome-cdp-manager 1.2.1 → 1.2.3

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/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "chrome-cdp-manager",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Set up and drive a Chrome DevTools Protocol (CDP) instance on macOS or Windows through a dedicated launcher (consistent Dock/taskbar icon). Works with any Chromium-based browser — Chrome, Edge, Brave, Chromium, Vivaldi, Opera, Arc.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
+ "types": "./types/index.d.ts",
7
8
  "exports": {
8
- ".": "./src/index.js",
9
- "./playwright": "./src/playwright.js",
9
+ ".": {
10
+ "types": "./types/index.d.ts",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./playwright": {
14
+ "types": "./types/playwright.d.ts",
15
+ "default": "./src/playwright.js"
16
+ },
10
17
  "./package.json": "./package.json"
11
18
  },
12
19
  "bin": {
@@ -16,6 +23,7 @@
16
23
  "files": [
17
24
  "bin",
18
25
  "src",
26
+ "types",
19
27
  "README.md",
20
28
  "LICENSE"
21
29
  ],
@@ -28,8 +36,10 @@
28
36
  ],
29
37
  "scripts": {
30
38
  "start": "node bin/cli.js",
31
- "check": "node --check bin/cli.js && for f in src/*.js src/launchers/*.js; do node --check \"$f\" || exit 1; done",
32
- "prepublishOnly": "npm run check",
39
+ "check": "node scripts/check.mjs",
40
+ "test": "node --test",
41
+ "build:types": "tsc -p tsconfig.json",
42
+ "prepublishOnly": "npm run check && npm test && npm run build:types",
33
43
  "release": "bash scripts/publish.sh"
34
44
  },
35
45
  "keywords": [
@@ -50,11 +60,14 @@
50
60
  "commander": "^12.1.0"
51
61
  },
52
62
  "peerDependencies": {
53
- "playwright": "*"
63
+ "playwright": ">=1.40.0"
54
64
  },
55
65
  "peerDependenciesMeta": {
56
66
  "playwright": {
57
67
  "optional": true
58
68
  }
69
+ },
70
+ "devDependencies": {
71
+ "typescript": "^5.6.0"
59
72
  }
60
73
  }
package/src/cdp.js CHANGED
@@ -2,19 +2,31 @@
2
2
  * Minimal Chrome DevTools Protocol client built on Node's global `fetch` and
3
3
  * `WebSocket` (Node >= 22). Avoids a heavyweight Playwright/Puppeteer
4
4
  * dependency so `npx` stays fast and browser-download free.
5
+ *
6
+ * Robustness contract:
7
+ * - Every {@link CdpClient#send} is bounded by a timeout (and an optional
8
+ * AbortSignal), so a missing response can never hang a caller forever.
9
+ * - If the socket drops, all in-flight `send`/`waitForEvent` promises reject
10
+ * instead of dangling.
11
+ * - {@link CdpClient#waitForEvent} supports multiple concurrent waiters for
12
+ * the same event.
5
13
  */
6
14
  export class CdpClient {
7
15
  #ws;
8
16
  #nextId = 0;
9
17
  #pending = new Map();
18
+ /** @type {Map<string, Set<{resolve, reject, timer}>>} */
10
19
  #eventWaiters = new Map();
20
+ #closed = false;
21
+ #defaultTimeoutMs;
11
22
 
12
- constructor(webSocketDebuggerUrl) {
23
+ constructor(webSocketDebuggerUrl, { timeoutMs = 30_000 } = {}) {
13
24
  this.url = webSocketDebuggerUrl;
25
+ this.#defaultTimeoutMs = timeoutMs;
14
26
  }
15
27
 
16
28
  /** Resolve the browser-level WebSocket endpoint and connect to it. */
17
- static async connect(cdpPort) {
29
+ static async connect(cdpPort, { timeoutMs } = {}) {
18
30
  const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
19
31
  if (!res.ok) {
20
32
  throw new Error(`CDP not reachable on port ${cdpPort} (HTTP ${res.status}).`);
@@ -23,7 +35,7 @@ export class CdpClient {
23
35
  if (!webSocketDebuggerUrl) {
24
36
  throw new Error("CDP did not advertise a webSocketDebuggerUrl.");
25
37
  }
26
- const client = new CdpClient(webSocketDebuggerUrl);
38
+ const client = new CdpClient(webSocketDebuggerUrl, { timeoutMs });
27
39
  await client.#open();
28
40
  return client;
29
41
  }
@@ -38,6 +50,12 @@ export class CdpClient {
38
50
  { once: true },
39
51
  );
40
52
  this.#ws.addEventListener("message", (ev) => this.#onMessage(ev));
53
+ // A drop after open must fail every in-flight call, not strand it.
54
+ this.#ws.addEventListener(
55
+ "close",
56
+ () => this.#fail(new Error("CDP WebSocket closed.")),
57
+ { once: true },
58
+ );
41
59
  });
42
60
  }
43
61
 
@@ -50,7 +68,8 @@ export class CdpClient {
50
68
  }
51
69
 
52
70
  if (data.id !== undefined && this.#pending.has(data.id)) {
53
- const { resolve, reject } = this.#pending.get(data.id);
71
+ const { resolve, reject, timer } = this.#pending.get(data.id);
72
+ if (timer) clearTimeout(timer);
54
73
  this.#pending.delete(data.id);
55
74
  if (data.error) reject(new Error(data.error.message));
56
75
  else resolve(data.result);
@@ -59,33 +78,134 @@ export class CdpClient {
59
78
 
60
79
  if (data.method) {
61
80
  const key = eventKey(data.method, data.sessionId);
62
- const waiter = this.#eventWaiters.get(key);
63
- if (waiter) {
81
+ const waiters = this.#eventWaiters.get(key);
82
+ if (waiters?.size) {
64
83
  this.#eventWaiters.delete(key);
65
- waiter(data.params);
84
+ for (const { resolve, timer } of waiters) {
85
+ if (timer) clearTimeout(timer);
86
+ resolve(data.params);
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ /** Reject everything in flight (socket closed / client closed). */
93
+ #fail(error) {
94
+ if (this.#closed) return;
95
+ this.#closed = true;
96
+ for (const { reject, timer } of this.#pending.values()) {
97
+ if (timer) clearTimeout(timer);
98
+ reject(error);
99
+ }
100
+ this.#pending.clear();
101
+ for (const waiters of this.#eventWaiters.values()) {
102
+ for (const { reject, timer } of waiters) {
103
+ if (timer) clearTimeout(timer);
104
+ reject(error);
66
105
  }
67
106
  }
107
+ this.#eventWaiters.clear();
68
108
  }
69
109
 
70
- /** Send a CDP command, optionally scoped to an attached session. */
71
- send(method, params = {}, sessionId) {
110
+ /**
111
+ * Send a CDP command, optionally scoped to an attached session.
112
+ * @param {string} method
113
+ * @param {object} [params]
114
+ * @param {string} [sessionId]
115
+ * @param {{ timeoutMs?: number, signal?: AbortSignal }} [opts]
116
+ */
117
+ send(method, params = {}, sessionId, { timeoutMs = this.#defaultTimeoutMs, signal } = {}) {
118
+ if (this.#closed) return Promise.reject(new Error("CDP client is closed."));
119
+ if (this.#ws?.readyState !== WebSocket.OPEN) {
120
+ return Promise.reject(new Error("CDP WebSocket is not open."));
121
+ }
122
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Aborted."));
123
+
72
124
  const id = ++this.#nextId;
73
125
  const message = { id, method, params };
74
126
  if (sessionId) message.sessionId = sessionId;
75
- this.#ws.send(JSON.stringify(message));
127
+
76
128
  return new Promise((resolve, reject) => {
77
- this.#pending.set(id, { resolve, reject });
129
+ const entry = { resolve, reject, timer: undefined };
130
+ if (timeoutMs && timeoutMs !== Infinity) {
131
+ entry.timer = setTimeout(() => {
132
+ this.#pending.delete(id);
133
+ reject(new Error(`CDP command "${method}" timed out after ${timeoutMs}ms.`));
134
+ }, timeoutMs);
135
+ }
136
+ if (signal) {
137
+ signal.addEventListener(
138
+ "abort",
139
+ () => {
140
+ if (entry.timer) clearTimeout(entry.timer);
141
+ this.#pending.delete(id);
142
+ reject(signal.reason ?? new Error("Aborted."));
143
+ },
144
+ { once: true },
145
+ );
146
+ }
147
+ this.#pending.set(id, entry);
148
+ try {
149
+ this.#ws.send(JSON.stringify(message));
150
+ } catch (err) {
151
+ if (entry.timer) clearTimeout(entry.timer);
152
+ this.#pending.delete(id);
153
+ reject(err);
154
+ }
78
155
  });
79
156
  }
80
157
 
81
- /** Resolve the next time `method` fires (for the given session, if any). */
82
- waitForEvent(method, sessionId) {
83
- return new Promise((resolve) => {
84
- this.#eventWaiters.set(eventKey(method, sessionId), resolve);
158
+ /**
159
+ * Resolve the next time `method` fires (for the given session, if any).
160
+ * Multiple waiters for the same event are all resolved.
161
+ * @param {string} method
162
+ * @param {string} [sessionId]
163
+ * @param {{ timeoutMs?: number, signal?: AbortSignal }} [opts]
164
+ */
165
+ waitForEvent(method, sessionId, { timeoutMs, signal } = {}) {
166
+ const key = eventKey(method, sessionId);
167
+ return new Promise((resolve, reject) => {
168
+ const entry = { resolve, reject, timer: undefined };
169
+ if (timeoutMs && timeoutMs !== Infinity) {
170
+ entry.timer = setTimeout(() => {
171
+ this.#removeWaiter(key, entry);
172
+ reject(new Error(`Timed out after ${timeoutMs}ms waiting for "${method}".`));
173
+ }, timeoutMs);
174
+ }
175
+ if (signal) {
176
+ if (signal.aborted) {
177
+ if (entry.timer) clearTimeout(entry.timer);
178
+ reject(signal.reason ?? new Error("Aborted."));
179
+ return;
180
+ }
181
+ signal.addEventListener(
182
+ "abort",
183
+ () => {
184
+ if (entry.timer) clearTimeout(entry.timer);
185
+ this.#removeWaiter(key, entry);
186
+ reject(signal.reason ?? new Error("Aborted."));
187
+ },
188
+ { once: true },
189
+ );
190
+ }
191
+ let set = this.#eventWaiters.get(key);
192
+ if (!set) {
193
+ set = new Set();
194
+ this.#eventWaiters.set(key, set);
195
+ }
196
+ set.add(entry);
85
197
  });
86
198
  }
87
199
 
200
+ #removeWaiter(key, entry) {
201
+ const set = this.#eventWaiters.get(key);
202
+ if (!set) return;
203
+ set.delete(entry);
204
+ if (!set.size) this.#eventWaiters.delete(key);
205
+ }
206
+
88
207
  close() {
208
+ this.#fail(new Error("CDP client closed by caller."));
89
209
  try {
90
210
  this.#ws?.close();
91
211
  } catch {
@@ -98,11 +218,31 @@ function eventKey(method, sessionId) {
98
218
  return sessionId ? `${sessionId}:${method}` : method;
99
219
  }
100
220
 
221
+ /**
222
+ * Strip "HeadlessChrome" from the browser's UA and apply the cleaned value to
223
+ * the given target so sites (and `navigator.userAgent`) see plain Chrome. A
224
+ * no-op for headed sessions, where the UA never contains "HeadlessChrome".
225
+ */
226
+ async function unmaskHeadlessUserAgent(cdp, sessionId) {
227
+ const { userAgent } = await cdp.send("Browser.getVersion").catch(() => ({}));
228
+ const clean = userAgent?.replace(/HeadlessChrome/g, "Chrome");
229
+ if (!clean || clean === userAgent) return;
230
+ await cdp.send("Network.setUserAgentOverride", { userAgent: clean }, sessionId);
231
+ }
232
+
101
233
  /** Open a URL in a new tab and leave it running. Returns the new targetId. */
102
234
  export async function openUrl(cdpPort, url) {
103
235
  const cdp = await CdpClient.connect(cdpPort);
104
236
  try {
105
- const { targetId } = await cdp.send("Target.createTarget", { url });
237
+ // Open blank, override the UA, then navigate — so the very first request
238
+ // carries the unmasked (non-headless) User-Agent.
239
+ const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
240
+ const { sessionId } = await cdp.send("Target.attachToTarget", {
241
+ targetId,
242
+ flatten: true,
243
+ });
244
+ await unmaskHeadlessUserAgent(cdp, sessionId);
245
+ await cdp.send("Page.navigate", { url }, sessionId);
106
246
  return targetId;
107
247
  } finally {
108
248
  cdp.close();
@@ -126,15 +266,12 @@ export async function getPageHtml(cdpPort, url, { close = false, timeoutMs = 30_
126
266
  });
127
267
 
128
268
  await cdp.send("Page.enable", {}, sessionId);
129
- const loaded = cdp.waitForEvent("Page.loadEventFired", sessionId);
269
+ await unmaskHeadlessUserAgent(cdp, sessionId);
270
+ // Register the waiter (with its own timeout) before navigating so a fast
271
+ // load can't fire the event before we're listening.
272
+ const loaded = cdp.waitForEvent("Page.loadEventFired", sessionId, { timeoutMs });
130
273
  await cdp.send("Page.navigate", { url }, sessionId);
131
-
132
- await Promise.race([
133
- loaded,
134
- delay(timeoutMs).then(() => {
135
- throw new Error(`Timed out loading ${url} after ${timeoutMs}ms.`);
136
- }),
137
- ]);
274
+ await loaded;
138
275
 
139
276
  const { result } = await cdp.send(
140
277
  "Runtime.evaluate",
@@ -162,7 +299,3 @@ export async function closeBrowser(cdpPort) {
162
299
  cdp.close();
163
300
  }
164
301
  }
165
-
166
- function delay(ms) {
167
- return new Promise((resolve) => setTimeout(resolve, ms));
168
- }
package/src/cli.js CHANGED
@@ -27,7 +27,19 @@ export async function run(argv) {
27
27
  "through a dedicated launcher (consistent Dock/taskbar icon). Works with " +
28
28
  "any Chromium-based browser.",
29
29
  )
30
- .version(version, "-v, --version");
30
+ .version(version, "-v, --version")
31
+ .addHelpText(
32
+ "after",
33
+ `
34
+ Default Headless Behavior:
35
+ By default, 'html' runs headlessly, and 'open' runs headed.
36
+ - To run 'html' with a visible window, use the --headed flag:
37
+ chrome-cdp html <url> --headed
38
+ - To run 'open' headlessly, use the --headless flag:
39
+ chrome-cdp open [url] --headless
40
+
41
+ Run 'chrome-cdp <command> --help' to see all options for a specific command.`,
42
+ );
31
43
 
32
44
  // Options shared across commands.
33
45
  const withCommon = (cmd) =>
@@ -63,7 +75,7 @@ export async function run(argv) {
63
75
  .command("open")
64
76
  .argument("[url]", "URL to open after launch")
65
77
  .description("Launch ChromeCDP via the launcher and optionally open a URL")
66
- .option("--headless", "Run headless (no window/Dock)", false)
78
+ .option("--headless", "Run headless (no window/Dock) (default: false)")
67
79
  .option("-t, --timeout <seconds>", "Startup timeout in seconds", "30"),
68
80
  ).action(openCommand);
69
81
 
@@ -72,14 +84,14 @@ export async function run(argv) {
72
84
  .command("html")
73
85
  .argument("<url>", "URL to load")
74
86
  .description("Print or save a page's HTML over CDP")
75
- .option("--headless", "Run headless (default for html)")
76
- .option("--headed", "Force a visible window")
87
+ .option("--headless", "Run headless (default: true)")
88
+ .option("--headed", "Force a visible window (default: false)")
77
89
  .option("--close", "Close the tab when done", false)
78
90
  .option("-o, --output <file>", "Write HTML to a file instead of stdout")
79
91
  .option("-t, --timeout <seconds>", "Load timeout in seconds", "30"),
80
92
  ).action((url, opts) => {
81
93
  // --headed wins over --headless when both are passed.
82
- const headless = opts.headed ? false : opts.headless;
94
+ const headless = opts.headed ? false : (opts.headless === undefined ? true : Boolean(opts.headless));
83
95
  return htmlCommand(url, { ...opts, headless });
84
96
  });
85
97
 
package/src/commands.js CHANGED
@@ -110,12 +110,15 @@ export async function openCommand(url, opts) {
110
110
 
111
111
  ensureLauncherReady(config);
112
112
 
113
- const { launched } = await ensureBrowserRunning(config, { headless, timeoutMs });
114
- console.error(
115
- launched
116
- ? `Launched ChromeCDP (${headless ? "headless" : "headed"}) on port ${config.cdpPort}.`
117
- : `ChromeCDP already running on port ${config.cdpPort}.`,
118
- );
113
+ const { launched, version } = await ensureBrowserRunning(config, { headless, timeoutMs });
114
+ if (launched) {
115
+ console.error(`Launched ChromeCDP (${headless ? "headless" : "headed"}) on port ${config.cdpPort}.`);
116
+ } else {
117
+ const isHeadless =
118
+ /HeadlessChrome/i.test(version.Browser) ||
119
+ /HeadlessChrome/i.test(version["User-Agent"] || "");
120
+ console.error(`ChromeCDP already running on port ${config.cdpPort} (${isHeadless ? "headless" : "headed"}).`);
121
+ }
119
122
 
120
123
  if (url) {
121
124
  const target = await openUrl(config.cdpPort, normalizeUrl(url));
@@ -133,20 +136,30 @@ export async function htmlCommand(url, opts) {
133
136
  const target = normalizeUrl(url);
134
137
 
135
138
  ensureLauncherReady(config);
136
- await ensureBrowserRunning(config, { headless, timeoutMs });
139
+ const { launched } = await ensureBrowserRunning(config, { headless, timeoutMs });
137
140
 
138
141
  console.error(`Fetching HTML for ${target} ...`);
139
- const html = await getPageHtml(config.cdpPort, target, {
140
- close: Boolean(opts.close),
141
- timeoutMs,
142
- });
143
-
144
- if (opts.output) {
145
- const outPath = path.resolve(opts.output);
146
- fs.writeFileSync(outPath, html);
147
- console.error(`Saved HTML to ${outPath}`);
148
- } else {
149
- process.stdout.write(html.endsWith("\n") ? html : `${html}\n`);
142
+ try {
143
+ const html = await getPageHtml(config.cdpPort, target, {
144
+ close: !launched || Boolean(opts.close),
145
+ timeoutMs,
146
+ });
147
+
148
+ if (opts.output) {
149
+ const outPath = path.resolve(opts.output);
150
+ fs.writeFileSync(outPath, html);
151
+ console.error(`Saved HTML to ${outPath}`);
152
+ } else {
153
+ process.stdout.write(html.endsWith("\n") ? html : `${html}\n`);
154
+ }
155
+ } finally {
156
+ if (launched && headless) {
157
+ try {
158
+ await closeBrowser(config.cdpPort);
159
+ } catch (err) {
160
+ // ignore errors during shutdown
161
+ }
162
+ }
150
163
  }
151
164
  }
152
165
 
@@ -154,9 +167,15 @@ export async function htmlCommand(url, opts) {
154
167
  export async function statusCommand(opts) {
155
168
  assertSupportedPlatform();
156
169
  const config = resolveConfig(opts);
170
+ const version = await probeCdp(config.cdpPort);
171
+
172
+ if (!version) {
173
+ console.log(`ChromeCDP is not running on port ${config.cdpPort}.`);
174
+ return;
175
+ }
176
+
157
177
  const launcher = getLauncher();
158
178
  const exists = launcher.exists(config);
159
- const version = await probeCdp(config.cdpPort);
160
179
 
161
180
  console.log("ChromeCDP status");
162
181
  console.log(` Browser: ${browserLabel(config.browser)} (${config.browserPath})`);
@@ -166,11 +185,14 @@ export async function statusCommand(opts) {
166
185
  );
167
186
  console.log(` Profile: ${config.profileDir}`);
168
187
  console.log(` CDP port: ${config.cdpPort}`);
169
- console.log(
170
- version
171
- ? ` Running: yes — ${version.Browser} (${version["Protocol-Version"] ?? "?"})`
172
- : " Running: no",
173
- );
188
+
189
+ const isHeadless =
190
+ /HeadlessChrome/i.test(version.Browser) ||
191
+ /HeadlessChrome/i.test(version["User-Agent"] || "");
192
+ const mode = isHeadless ? "headless" : "headed";
193
+ const runningDetail = `yes — ${version.Browser} (${version["Protocol-Version"] ?? "?"}, ${mode})`;
194
+
195
+ console.log(` Running: ${runningDetail}`);
174
196
  console.log(` Config: ${CONFIG_FILE}`);
175
197
  }
176
198
 
package/src/index.js CHANGED
@@ -16,19 +16,39 @@ export { loadConfig, computeDefaults, DEFAULTS } from "./config.js";
16
16
  export { getLauncher } from "./launcher.js";
17
17
  export { ensureBrowserRunning, probeCdp, waitForCdp } from "./browser.js";
18
18
 
19
+ // Values the launcher bakes in at creation time; overriding them only takes
20
+ // effect if the launcher is (re)created to match.
21
+ const BAKED_KEYS = ["cdpPort", "profileDir", "browserPath"];
22
+
19
23
  /**
20
24
  * Ensure a ChromeCDP instance is running and return its connection details.
21
25
  *
22
26
  * @param {object} [opts]
23
27
  * @param {boolean} [opts.headless=false]
24
28
  * @param {number} [opts.timeoutMs=30000] startup timeout in milliseconds.
29
+ * @param {boolean} [opts.force=false] rewrite the launcher even if it exists.
25
30
  * @param {object} [opts.config] overrides merged over the persisted config
26
- * (e.g. `{ cdpPort, profileDir, browserPath }`).
31
+ * (e.g. `{ cdpPort, profileDir, browserPath }`). If these
32
+ * differ from your saved `setup`, the launcher is rewritten so
33
+ * the override actually takes effect (rather than being
34
+ * silently ignored).
27
35
  * @returns {Promise<{ config: object, endpoint: string, launched: boolean, version: object }>}
28
36
  */
29
- export async function launch({ headless = false, timeoutMs = 30_000, config: overrides } = {}) {
30
- const config = { ...loadConfig(), ...overrides };
31
- getLauncher().ensure(config, { force: false });
37
+ export async function launch({ headless = false, timeoutMs = 30_000, force = false, config: overrides } = {}) {
38
+ const persisted = loadConfig();
39
+ const config = { ...persisted, ...overrides };
40
+ const launcher = getLauncher();
41
+
42
+ // If a caller overrode a baked value, the existing launcher would still use
43
+ // the old one — so rewrite it to match instead of producing a port/profile
44
+ // mismatch that times out at connect time.
45
+ const diverged =
46
+ overrides &&
47
+ launcher.exists(config) &&
48
+ BAKED_KEYS.some((k) => overrides[k] !== undefined && overrides[k] !== persisted[k]);
49
+
50
+ launcher.ensure(config, { force: force || Boolean(diverged) });
51
+
32
52
  const { launched, version } = await ensureBrowserRunning(config, { headless, timeoutMs });
33
53
  return {
34
54
  config,
@@ -1,7 +1,10 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { spawn, execFileSync } from "node:child_process";
4
5
 
6
+ import { getWindowSizeFromProfile } from "../preferences.js";
7
+
5
8
  /**
6
9
  * macOS launcher: a real `.app` bundle whose executable is a small bash
7
10
  * launcher. Headed launches go through `open <bundle>` so LaunchServices
@@ -14,6 +17,9 @@ import { spawn, execFileSync } from "node:child_process";
14
17
  * as `--headless=new`.
15
18
  */
16
19
  function launcherScript({ browserPath, cdpPort, profileDir }) {
20
+ // `arch -arm64` is meaningful only on Apple Silicon; on Intel it would error,
21
+ // so omit it there and exec the browser directly.
22
+ const archPrefix = os.arch() === "arm64" ? "arch -arm64 " : "";
17
23
  return `#!/usr/bin/env bash
18
24
  # ChromeCDP launcher — generated by chrome-cdp-manager.
19
25
  # Re-run \`chrome-cdp setup\` to regenerate this file.
@@ -23,7 +29,7 @@ BROWSER=${shellQuote(browserPath)}
23
29
  PORT=${shellQuote(String(cdpPort))}
24
30
  PROFILE=${shellQuote(profileDir)}
25
31
 
26
- exec arch -arm64 "$BROWSER" \\
32
+ exec ${archPrefix}"$BROWSER" \\
27
33
  --remote-debugging-port="$PORT" \\
28
34
  --user-data-dir="$PROFILE" \\
29
35
  "$@"
@@ -160,7 +166,13 @@ export function ensure(config, { force = false } = {}) {
160
166
  export function launch(config, { headless } = {}) {
161
167
  if (headless) {
162
168
  const { executable } = bundleLayout(config.launcherPath);
163
- const child = spawn(executable, ["--headless=new"], {
169
+ const args = ["--headless=new"];
170
+ const size = getWindowSizeFromProfile(config.profileDir);
171
+ if (size) {
172
+ args.push(`--window-size=${size.width},${size.height}`);
173
+ args.push(`--screen-info={0,0 ${size.width}x${size.height}}`);
174
+ }
175
+ const child = spawn(executable, args, {
164
176
  detached: true,
165
177
  stdio: "ignore",
166
178
  });
@@ -3,6 +3,8 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawn, execFileSync } from "node:child_process";
5
5
 
6
+ import { getWindowSizeFromProfile } from "../preferences.js";
7
+
6
8
  /**
7
9
  * Windows launcher: a Start Menu `.lnk` shortcut with a custom icon and the CDP
8
10
  * flags baked into its arguments. Headed launches go through the shortcut so
@@ -86,13 +88,19 @@ export function ensure(config, { force = false } = {}) {
86
88
  */
87
89
  export function launch(config, { headless } = {}) {
88
90
  if (headless) {
91
+ const args = [
92
+ `--remote-debugging-port=${config.cdpPort}`,
93
+ `--user-data-dir=${config.profileDir}`,
94
+ "--headless=new",
95
+ ];
96
+ const size = getWindowSizeFromProfile(config.profileDir);
97
+ if (size) {
98
+ args.push(`--window-size=${size.width},${size.height}`);
99
+ args.push(`--screen-info={0,0 ${size.width}x${size.height}}`);
100
+ }
89
101
  const child = spawn(
90
102
  config.browserPath,
91
- [
92
- `--remote-debugging-port=${config.cdpPort}`,
93
- `--user-data-dir=${config.profileDir}`,
94
- "--headless=new",
95
- ],
103
+ args,
96
104
  { detached: true, stdio: "ignore" },
97
105
  );
98
106
  child.unref();
package/src/playwright.js CHANGED
@@ -18,15 +18,18 @@ import { launch } from "./index.js";
18
18
  * falls back to the first tab, then a fresh one.
19
19
  * @param {number} [opts.timeoutMs=30000]
20
20
  * @param {object} [opts.config] config overrides (see {@link launch}).
21
+ * @param {boolean} [opts.force=false] rewrite the launcher even if it exists.
22
+ * @param {boolean} [opts.keepOpen=true] on dispose, detach and leave the
23
+ * launcher-managed browser running; set `false` to fully close it.
21
24
  * @param {object} [opts.chromium] a Playwright `chromium` object to use
22
25
  * instead of `import("playwright")` — for injected/zero-install
23
26
  * setups.
24
27
  * @returns {Promise<{ browser, context, page, config } & AsyncDisposable>}
25
- * Dispose (or `await using`) detaches the CDP channel; the
28
+ * Dispose (or `await using`) detaches the CDP channel by default; the
26
29
  * launcher-managed browser keeps running.
27
30
  */
28
- export async function connect({ headless = false, match, timeoutMs = 30_000, config: overrides, chromium } = {}) {
29
- const { config, endpoint } = await launch({ headless, timeoutMs, config: overrides });
31
+ export async function connect({ headless = false, match, timeoutMs = 30_000, force = false, keepOpen = true, config: overrides, chromium } = {}) {
32
+ const { config, endpoint } = await launch({ headless, timeoutMs, force, config: overrides });
30
33
 
31
34
  const pwChromium = chromium ?? (await importPlaywright()).chromium;
32
35
  const browser = await pwChromium.connectOverCDP(endpoint);
@@ -44,7 +47,14 @@ export async function connect({ headless = false, match, timeoutMs = 30_000, con
44
47
  page,
45
48
  config,
46
49
  async [Symbol.asyncDispose]() {
47
- await browser.close();
50
+ // Detach (don't kill) by default: this browser is launcher-managed and
51
+ // may be shared with other sessions. `browser.close()` on a CDP-connected
52
+ // browser tends to terminate it, so prefer disconnect().
53
+ if (keepOpen && typeof browser.disconnect === "function") {
54
+ await browser.disconnect();
55
+ } else {
56
+ await browser.close();
57
+ }
48
58
  },
49
59
  };
50
60
  }
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Reads the last closed window dimensions from the Chrome profile Preferences file.
6
+ * @param {string} profileDir
7
+ * @returns {{ width: number, height: number, maximized: boolean } | null}
8
+ */
9
+ export function getWindowSizeFromProfile(profileDir) {
10
+ try {
11
+ const preferencesPath = path.join(profileDir, "Default", "Preferences");
12
+ if (fs.existsSync(preferencesPath)) {
13
+ const content = fs.readFileSync(preferencesPath, "utf8");
14
+ const prefs = JSON.parse(content);
15
+ const placement = prefs?.browser?.window_placement;
16
+ if (placement) {
17
+ const { left, top, right, bottom, maximized } = placement;
18
+ const width = right - left;
19
+ const height = bottom - top;
20
+ if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
21
+ return { width, height, maximized: Boolean(maximized) };
22
+ }
23
+ }
24
+ }
25
+ } catch {
26
+ // Ignore and fallback
27
+ }
28
+ return null;
29
+ }
@@ -0,0 +1,12 @@
1
+ /** Returns the CDP version payload if the browser is already listening, else null. */
2
+ export function probeCdp(cdpPort: any): Promise<any>;
3
+ /** Poll the CDP endpoint until it responds or the timeout elapses. */
4
+ export function waitForCdp(cdpPort: any, timeoutMs?: number): Promise<any>;
5
+ /**
6
+ * Ensure the browser is running and CDP is reachable, launching it if needed.
7
+ * @returns {Promise<{ launched: boolean, version: object }>}
8
+ */
9
+ export function ensureBrowserRunning(config: any, { headless, timeoutMs }?: {}): Promise<{
10
+ launched: boolean;
11
+ version: object;
12
+ }>;
@@ -0,0 +1,27 @@
1
+ /** All known browser keys, in display order. */
2
+ export function browserKeys(): string[];
3
+ /** Human label for a browser key (falls back to the key itself). */
4
+ export function browserLabel(key: any): any;
5
+ /**
6
+ * Resolve a browser key to a concrete location on the current platform.
7
+ * @returns {{ key, label, path, icon: string|null, found: boolean }}
8
+ * `found` is whether the executable exists on disk. `path` is the best
9
+ * candidate even when not found, so callers can show a helpful message.
10
+ */
11
+ export function resolveBrowser(key: any, platform?: any): {
12
+ key: any;
13
+ label: any;
14
+ path: any;
15
+ icon: string | null;
16
+ found: boolean;
17
+ };
18
+ /** All browsers detected as installed on the current platform. */
19
+ export function detectInstalled(platform?: any): {
20
+ key: any;
21
+ label: any;
22
+ path: any;
23
+ icon: string | null;
24
+ found: boolean;
25
+ }[];
26
+ /** The canonical default browser key. */
27
+ export const DEFAULT_BROWSER: "chrome";
package/types/cdp.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ /** Open a URL in a new tab and leave it running. Returns the new targetId. */
2
+ export function openUrl(cdpPort: any, url: any): Promise<any>;
3
+ /**
4
+ * Navigate to a URL and return the page's serialized HTML.
5
+ * @param {object} opts
6
+ * @param {boolean} [opts.close] close the tab after capturing.
7
+ * @param {number} [opts.timeoutMs] navigation timeout.
8
+ */
9
+ export function getPageHtml(cdpPort: any, url: any, { close, timeoutMs }?: {
10
+ close?: boolean;
11
+ timeoutMs?: number;
12
+ }): Promise<any>;
13
+ /** Close the entire browser via CDP (graceful quit). */
14
+ export function closeBrowser(cdpPort: any): Promise<void>;
15
+ /**
16
+ * Minimal Chrome DevTools Protocol client built on Node's global `fetch` and
17
+ * `WebSocket` (Node >= 22). Avoids a heavyweight Playwright/Puppeteer
18
+ * dependency so `npx` stays fast and browser-download free.
19
+ *
20
+ * Robustness contract:
21
+ * - Every {@link CdpClient#send} is bounded by a timeout (and an optional
22
+ * AbortSignal), so a missing response can never hang a caller forever.
23
+ * - If the socket drops, all in-flight `send`/`waitForEvent` promises reject
24
+ * instead of dangling.
25
+ * - {@link CdpClient#waitForEvent} supports multiple concurrent waiters for
26
+ * the same event.
27
+ */
28
+ export class CdpClient {
29
+ /** Resolve the browser-level WebSocket endpoint and connect to it. */
30
+ static connect(cdpPort: any, { timeoutMs }?: {}): Promise<CdpClient>;
31
+ constructor(webSocketDebuggerUrl: any, { timeoutMs }?: {
32
+ timeoutMs?: number;
33
+ });
34
+ url: any;
35
+ /**
36
+ * Send a CDP command, optionally scoped to an attached session.
37
+ * @param {string} method
38
+ * @param {object} [params]
39
+ * @param {string} [sessionId]
40
+ * @param {{ timeoutMs?: number, signal?: AbortSignal }} [opts]
41
+ */
42
+ send(method: string, params?: object, sessionId?: string, { timeoutMs, signal }?: {
43
+ timeoutMs?: number;
44
+ signal?: AbortSignal;
45
+ }): Promise<any>;
46
+ /**
47
+ * Resolve the next time `method` fires (for the given session, if any).
48
+ * Multiple waiters for the same event are all resolved.
49
+ * @param {string} method
50
+ * @param {string} [sessionId]
51
+ * @param {{ timeoutMs?: number, signal?: AbortSignal }} [opts]
52
+ */
53
+ waitForEvent(method: string, sessionId?: string, { timeoutMs, signal }?: {
54
+ timeoutMs?: number;
55
+ signal?: AbortSignal;
56
+ }): Promise<any>;
57
+ close(): void;
58
+ #private;
59
+ }
package/types/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export function run(argv: any): Promise<void>;
@@ -0,0 +1,8 @@
1
+ export function setupCommand(opts: any): Promise<void>;
2
+ export function openCommand(url: any, opts: any): Promise<void>;
3
+ export function htmlCommand(url: any, opts: any): Promise<void>;
4
+ export function statusCommand(opts: any): Promise<void>;
5
+ export function stopCommand(opts: any): Promise<void>;
6
+ export function browsersCommand(): Promise<void>;
7
+ export { DEFAULTS };
8
+ import { DEFAULTS } from "./config.js";
@@ -0,0 +1,28 @@
1
+ /** Compute defaults for a given browser/platform (binary, icon, launcher). */
2
+ export function computeDefaults({ browser, platform, }?: {
3
+ browser?: string;
4
+ platform?: any;
5
+ }): {
6
+ browser: string;
7
+ browserPath: any;
8
+ browserIcon: string;
9
+ launcherPath: any;
10
+ bundleId: string;
11
+ cdpPort: number;
12
+ profileDir: any;
13
+ };
14
+ /** Load persisted config, falling back to {@link DEFAULTS} for missing keys. */
15
+ export function loadConfig(): any;
16
+ /** Persist the resolved config so other commands stay in sync with the launcher. */
17
+ export function saveConfig(config: any): any;
18
+ /** Defaults for the current platform and default browser (for help text). */
19
+ export const DEFAULTS: Readonly<{
20
+ browser: string;
21
+ browserPath: any;
22
+ browserIcon: string;
23
+ launcherPath: any;
24
+ bundleId: string;
25
+ cdpPort: number;
26
+ profileDir: any;
27
+ }>;
28
+ export const CONFIG_FILE: any;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Ensure a ChromeCDP instance is running and return its connection details.
3
+ *
4
+ * @param {object} [opts]
5
+ * @param {boolean} [opts.headless=false]
6
+ * @param {number} [opts.timeoutMs=30000] startup timeout in milliseconds.
7
+ * @param {boolean} [opts.force=false] rewrite the launcher even if it exists.
8
+ * @param {object} [opts.config] overrides merged over the persisted config
9
+ * (e.g. `{ cdpPort, profileDir, browserPath }`). If these
10
+ * differ from your saved `setup`, the launcher is rewritten so
11
+ * the override actually takes effect (rather than being
12
+ * silently ignored).
13
+ * @returns {Promise<{ config: object, endpoint: string, launched: boolean, version: object }>}
14
+ */
15
+ export function launch({ headless, timeoutMs, force, config: overrides }?: {
16
+ headless?: boolean;
17
+ timeoutMs?: number;
18
+ force?: boolean;
19
+ config?: object;
20
+ }): Promise<{
21
+ config: object;
22
+ endpoint: string;
23
+ launched: boolean;
24
+ version: object;
25
+ }>;
26
+ export { getLauncher } from "./launcher.js";
27
+ export { CdpClient, openUrl, getPageHtml, closeBrowser } from "./cdp.js";
28
+ export { loadConfig, computeDefaults, DEFAULTS } from "./config.js";
29
+ export { ensureBrowserRunning, probeCdp, waitForCdp } from "./browser.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Select the platform-specific launcher. Each implementation exposes the same
3
+ * surface: `ensure`, `exists`, `launch`, `targetPath`, `targetLabel`.
4
+ *
5
+ * Adding Linux later means dropping a `linuxDesktop.js` here that builds a
6
+ * `.desktop` entry — no other module needs to change.
7
+ */
8
+ export function getLauncher(platform?: any): typeof macBundle | typeof winShortcut;
9
+ import * as macBundle from "./launchers/macBundle.js";
10
+ import * as winShortcut from "./launchers/winShortcut.js";
@@ -0,0 +1,20 @@
1
+ /** Path shown in `status` for the launcher target. */
2
+ export function targetPath(config: any): any;
3
+ /** True when the bundle's executable launcher already exists. */
4
+ export function exists(config: any): any;
5
+ /**
6
+ * Create or repair the ChromeCDP.app bundle so its Dock icon and launch
7
+ * behaviour are correct and consistent.
8
+ * @returns {{ created: boolean }} whether the bundle was newly created.
9
+ */
10
+ export function ensure(config: any, { force }?: {
11
+ force?: boolean;
12
+ }): {
13
+ created: boolean;
14
+ };
15
+ /**
16
+ * Launch the browser through the bundle. Headed goes via `open <bundle>` so
17
+ * LaunchServices attaches the Dock icon; headless runs the launcher directly.
18
+ */
19
+ export function launch(config: any, { headless }?: {}): void;
20
+ export const targetLabel: "App bundle";
@@ -0,0 +1,19 @@
1
+ /** Path shown in `status` for the launcher target. */
2
+ export function targetPath(config: any): any;
3
+ /** True when the shortcut file already exists. */
4
+ export function exists(config: any): any;
5
+ /**
6
+ * Create or repair the ChromeCDP shortcut.
7
+ * @returns {{ created: boolean }} whether the shortcut was newly created.
8
+ */
9
+ export function ensure(config: any, { force }?: {
10
+ force?: boolean;
11
+ }): {
12
+ created: boolean;
13
+ };
14
+ /**
15
+ * Launch the browser. Headed goes through the shortcut so the taskbar uses its
16
+ * icon; headless runs the exe directly with the same flags + `--headless=new`.
17
+ */
18
+ export function launch(config: any, { headless }?: {}): void;
19
+ export const targetLabel: "Shortcut";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * This tool builds a platform-native launcher (a macOS `.app` bundle or a
3
+ * Windows `.lnk` shortcut). Only those two platforms are supported today.
4
+ */
5
+ export function assertSupportedPlatform(): void;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Launch (or attach to) ChromeCDP and return a ready-to-drive Playwright page.
3
+ *
4
+ * @param {object} [opts]
5
+ * @param {boolean} [opts.headless=false]
6
+ * @param {(url: string) => boolean} [opts.match] pick an existing tab by URL;
7
+ * falls back to the first tab, then a fresh one.
8
+ * @param {number} [opts.timeoutMs=30000]
9
+ * @param {object} [opts.config] config overrides (see {@link launch}).
10
+ * @param {boolean} [opts.force=false] rewrite the launcher even if it exists.
11
+ * @param {boolean} [opts.keepOpen=true] on dispose, detach and leave the
12
+ * launcher-managed browser running; set `false` to fully close it.
13
+ * @param {object} [opts.chromium] a Playwright `chromium` object to use
14
+ * instead of `import("playwright")` — for injected/zero-install
15
+ * setups.
16
+ * @returns {Promise<{ browser, context, page, config } & AsyncDisposable>}
17
+ * Dispose (or `await using`) detaches the CDP channel by default; the
18
+ * launcher-managed browser keeps running.
19
+ */
20
+ export function connect({ headless, match, timeoutMs, force, keepOpen, config: overrides, chromium }?: {
21
+ headless?: boolean;
22
+ match?: (url: string) => boolean;
23
+ timeoutMs?: number;
24
+ config?: object;
25
+ force?: boolean;
26
+ keepOpen?: boolean;
27
+ chromium?: object;
28
+ }): Promise<{
29
+ browser: any;
30
+ context: any;
31
+ page: any;
32
+ config: any;
33
+ } & AsyncDisposable>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Reads the last closed window dimensions from the Chrome profile Preferences file.
3
+ * @param {string} profileDir
4
+ * @returns {{ width: number, height: number, maximized: boolean } | null}
5
+ */
6
+ export function getWindowSizeFromProfile(profileDir: string): {
7
+ width: number;
8
+ height: number;
9
+ maximized: boolean;
10
+ } | null;