chrome-cdp-manager 1.2.1 → 1.2.2
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 +19 -6
- package/src/cdp.js +161 -28
- package/src/cli.js +17 -5
- package/src/commands.js +24 -12
- package/src/index.js +24 -4
- package/src/launchers/macBundle.js +5 -1
- package/src/playwright.js +14 -4
- package/types/browser.d.ts +12 -0
- package/types/browsers.d.ts +27 -0
- package/types/cdp.d.ts +59 -0
- package/types/cli.d.ts +1 -0
- package/types/commands.d.ts +8 -0
- package/types/config.d.ts +28 -0
- package/types/index.d.ts +29 -0
- package/types/launcher.d.ts +10 -0
- package/types/launchers/macBundle.d.ts +20 -0
- package/types/launchers/winShortcut.d.ts +19 -0
- package/types/platform.d.ts +5 -0
- package/types/playwright.d.ts +33 -0
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-cdp-manager",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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
|
-
".":
|
|
9
|
-
|
|
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
|
|
32
|
-
"
|
|
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
|
|
63
|
-
if (
|
|
81
|
+
const waiters = this.#eventWaiters.get(key);
|
|
82
|
+
if (waiters?.size) {
|
|
64
83
|
this.#eventWaiters.delete(key);
|
|
65
|
-
|
|
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
|
-
/**
|
|
71
|
-
|
|
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
|
-
|
|
127
|
+
|
|
76
128
|
return new Promise((resolve, reject) => {
|
|
77
|
-
|
|
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
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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));
|
|
@@ -154,9 +157,15 @@ export async function htmlCommand(url, opts) {
|
|
|
154
157
|
export async function statusCommand(opts) {
|
|
155
158
|
assertSupportedPlatform();
|
|
156
159
|
const config = resolveConfig(opts);
|
|
160
|
+
const version = await probeCdp(config.cdpPort);
|
|
161
|
+
|
|
162
|
+
if (!version) {
|
|
163
|
+
console.log(`ChromeCDP is not running on port ${config.cdpPort}.`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
157
167
|
const launcher = getLauncher();
|
|
158
168
|
const exists = launcher.exists(config);
|
|
159
|
-
const version = await probeCdp(config.cdpPort);
|
|
160
169
|
|
|
161
170
|
console.log("ChromeCDP status");
|
|
162
171
|
console.log(` Browser: ${browserLabel(config.browser)} (${config.browserPath})`);
|
|
@@ -166,11 +175,14 @@ export async function statusCommand(opts) {
|
|
|
166
175
|
);
|
|
167
176
|
console.log(` Profile: ${config.profileDir}`);
|
|
168
177
|
console.log(` CDP port: ${config.cdpPort}`);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
178
|
+
|
|
179
|
+
const isHeadless =
|
|
180
|
+
/HeadlessChrome/i.test(version.Browser) ||
|
|
181
|
+
/HeadlessChrome/i.test(version["User-Agent"] || "");
|
|
182
|
+
const mode = isHeadless ? "headless" : "headed";
|
|
183
|
+
const runningDetail = `yes — ${version.Browser} (${version["Protocol-Version"] ?? "?"}, ${mode})`;
|
|
184
|
+
|
|
185
|
+
console.log(` Running: ${runningDetail}`);
|
|
174
186
|
console.log(` Config: ${CONFIG_FILE}`);
|
|
175
187
|
}
|
|
176
188
|
|
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
|
|
31
|
-
|
|
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,4 +1,5 @@
|
|
|
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
|
|
|
@@ -14,6 +15,9 @@ import { spawn, execFileSync } from "node:child_process";
|
|
|
14
15
|
* as `--headless=new`.
|
|
15
16
|
*/
|
|
16
17
|
function launcherScript({ browserPath, cdpPort, profileDir }) {
|
|
18
|
+
// `arch -arm64` is meaningful only on Apple Silicon; on Intel it would error,
|
|
19
|
+
// so omit it there and exec the browser directly.
|
|
20
|
+
const archPrefix = os.arch() === "arm64" ? "arch -arm64 " : "";
|
|
17
21
|
return `#!/usr/bin/env bash
|
|
18
22
|
# ChromeCDP launcher — generated by chrome-cdp-manager.
|
|
19
23
|
# Re-run \`chrome-cdp setup\` to regenerate this file.
|
|
@@ -23,7 +27,7 @@ BROWSER=${shellQuote(browserPath)}
|
|
|
23
27
|
PORT=${shellQuote(String(cdpPort))}
|
|
24
28
|
PROFILE=${shellQuote(profileDir)}
|
|
25
29
|
|
|
26
|
-
exec
|
|
30
|
+
exec ${archPrefix}"$BROWSER" \\
|
|
27
31
|
--remote-debugging-port="$PORT" \\
|
|
28
32
|
--user-data-dir="$PROFILE" \\
|
|
29
33
|
"$@"
|
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
|
-
|
|
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,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;
|
package/types/index.d.ts
ADDED
|
@@ -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,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>;
|