clearcote 0.28.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -20
- package/dist/clearcote-cli.d.ts +3 -0
- package/dist/clearcote-cli.d.ts.map +1 -0
- package/dist/clearcote-cli.js +9 -0
- package/dist/clearcote-cli.js.map +1 -0
- package/dist/cli-commands.d.ts +62 -0
- package/dist/cli-commands.d.ts.map +1 -0
- package/dist/cli-commands.js +333 -0
- package/dist/cli-commands.js.map +1 -0
- package/dist/download.d.ts +28 -0
- package/dist/download.d.ts.map +1 -1
- package/dist/download.js +56 -6
- package/dist/download.js.map +1 -1
- package/dist/fingerprint.d.ts +16 -0
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +37 -0
- package/dist/fingerprint.js.map +1 -1
- package/dist/geoip.d.ts +37 -1
- package/dist/geoip.d.ts.map +1 -1
- package/dist/geoip.js +90 -68
- package/dist/geoip.js.map +1 -1
- package/dist/index.d.ts +49 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +118 -50
- package/dist/index.js.map +1 -1
- package/dist/launchopts.d.ts +44 -0
- package/dist/launchopts.d.ts.map +1 -1
- package/dist/launchopts.js +78 -0
- package/dist/launchopts.js.map +1 -1
- package/dist/license.d.ts +69 -4
- package/dist/license.d.ts.map +1 -1
- package/dist/license.js +355 -28
- package/dist/license.js.map +1 -1
- package/dist/multiplex.d.ts +111 -0
- package/dist/multiplex.d.ts.map +1 -0
- package/dist/multiplex.js +536 -0
- package/dist/multiplex.js.map +1 -0
- package/dist/net.d.ts +36 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +278 -0
- package/dist/net.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { type ServeOptions } from "./index.js";
|
|
3
|
+
export interface MultiplexOptions extends Omit<ServeOptions, "port" | "host" | "userDataDir" | "allowOrigins" | "readyTimeoutMs"> {
|
|
4
|
+
/** Port for the multiplexer (default 9222; 0 = ephemeral). */
|
|
5
|
+
port?: number;
|
|
6
|
+
/** Bind address (default 127.0.0.1). */
|
|
7
|
+
host?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Close an identity's browser this many seconds after its last WebSocket disconnects (0 = never,
|
|
10
|
+
* the default). Also read from CLEARCOTE_SERVE_IDLE_TIMEOUT.
|
|
11
|
+
*/
|
|
12
|
+
idleTimeoutSec?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Keep each identity's profile under this directory (sub-directory named by a hash of the identity),
|
|
15
|
+
* so cookies and storage survive restarts. Default: a temporary profile per browser, deleted when it
|
|
16
|
+
* closes.
|
|
17
|
+
*/
|
|
18
|
+
dataDir?: string;
|
|
19
|
+
/** Maximum concurrent browsers (default 16). Further identities get HTTP 429. */
|
|
20
|
+
maxBrowsers?: number;
|
|
21
|
+
/** Extra browser Origins (exact, e.g. "https://tool.example") allowed to reach the endpoint. */
|
|
22
|
+
allowOrigins?: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Host names accepted in the Host header besides IP literals and "localhost" (DNS-rebinding
|
|
25
|
+
* guard), e.g. "cdp.example.com" when a reverse proxy forwards that Host.
|
|
26
|
+
*/
|
|
27
|
+
allowHosts?: string[];
|
|
28
|
+
/** Per-browser startup timeout in ms (default 30000). */
|
|
29
|
+
readyTimeoutMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* How a browser is started for an identity. Defaults to {@link serve}. Exists so the routing,
|
|
32
|
+
* limits and WebSocket relay can be tested without a browser binary.
|
|
33
|
+
*/
|
|
34
|
+
startBrowser?: (options: ServeOptions) => Promise<BrowserHandle>;
|
|
35
|
+
}
|
|
36
|
+
/** What the multiplexer needs from a started browser: its loopback CDP port, pid and a close(). */
|
|
37
|
+
export interface BrowserHandle {
|
|
38
|
+
readonly port: number;
|
|
39
|
+
readonly pid?: number;
|
|
40
|
+
close(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export interface MultiplexServer {
|
|
43
|
+
readonly host: string;
|
|
44
|
+
readonly port: number;
|
|
45
|
+
/** `http://host:port` — pass to connectOverCDP (optionally with `?fingerprint=...`). */
|
|
46
|
+
readonly url: string;
|
|
47
|
+
/** Snapshot of the running browsers. */
|
|
48
|
+
status(): MultiplexStatus;
|
|
49
|
+
/** Close one identity's browser. Resolves true when one was running. */
|
|
50
|
+
closeIdentity(id: string): Promise<boolean>;
|
|
51
|
+
/** Stop the server and every browser. */
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export interface MultiplexStatus {
|
|
55
|
+
status: "ok";
|
|
56
|
+
active: number;
|
|
57
|
+
maxBrowsers: number;
|
|
58
|
+
idleTimeoutSec: number;
|
|
59
|
+
processes: Array<{
|
|
60
|
+
id: string;
|
|
61
|
+
seed?: string;
|
|
62
|
+
pid?: number;
|
|
63
|
+
port: number;
|
|
64
|
+
connections: number;
|
|
65
|
+
startedAt: string;
|
|
66
|
+
idleSince?: string;
|
|
67
|
+
params: Record<string, string>;
|
|
68
|
+
}>;
|
|
69
|
+
}
|
|
70
|
+
/** A seed usable in a URL path segment. Leading `_` is reserved for parameter-only identities. */
|
|
71
|
+
export declare const SEED_PATTERN: RegExp;
|
|
72
|
+
/** Query parameter (kebab-case) -> launch option name, for every parameter a connection may set. */
|
|
73
|
+
export declare const QUERY_PARAMS: Readonly<Record<string, string>>;
|
|
74
|
+
export declare class MultiplexRequestError extends Error {
|
|
75
|
+
readonly status: number;
|
|
76
|
+
constructor(status: number, message: string);
|
|
77
|
+
}
|
|
78
|
+
/** Parsed per-connection identity: the id used in paths, and the launch options it adds. */
|
|
79
|
+
export interface ConnectionIdentity {
|
|
80
|
+
/** "default", a seed, or "_p<hash>" for a parameter-only identity. */
|
|
81
|
+
id: string;
|
|
82
|
+
seed?: string;
|
|
83
|
+
options: Record<string, unknown>;
|
|
84
|
+
/** The raw accepted parameters (proxy credentials redacted), for the status page. */
|
|
85
|
+
params: Record<string, string>;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Turn a connection's query string into an identity. Unknown parameters are rejected with 400
|
|
89
|
+
* rather than guessed at, so a typo never silently launches a different identity.
|
|
90
|
+
*/
|
|
91
|
+
export declare function parseConnectionIdentity(search: URLSearchParams): ConnectionIdentity;
|
|
92
|
+
/** Public ws(s):// base for URLs we hand back, honouring a reverse proxy's forwarded headers. */
|
|
93
|
+
export declare function publicWsBase(headers: http.IncomingHttpHeaders, fallbackHost: string): string;
|
|
94
|
+
/** Rewrite a child's ws://127.0.0.1:<port>/devtools/... URL to route through the multiplexer. */
|
|
95
|
+
export declare function rewriteWsUrl(url: string, wsBase: string, id: string): string;
|
|
96
|
+
/** Origin allowed to open a CDP WebSocket: none (non-browser client), loopback, or explicitly listed. */
|
|
97
|
+
export declare function originAllowed(origin: string | undefined, extra?: readonly string[]): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Host header allowed: an IP literal or "localhost" (the rule Chrome's own DevTools server uses
|
|
100
|
+
* against DNS rebinding), or a name in `extra` for a deployment behind a reverse proxy.
|
|
101
|
+
*/
|
|
102
|
+
export declare function hostAllowed(hostHeader: string | undefined, extra?: readonly string[]): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Refuse requests a web page could have made: a cross-site/same-site fetch (Sec-Fetch-Site), a
|
|
105
|
+
* browser Origin that is not loopback or allowed, or a Host that is not ours (DNS rebinding).
|
|
106
|
+
* Playwright, Puppeteer and curl send none of these, so real clients are unaffected.
|
|
107
|
+
*/
|
|
108
|
+
export declare function requestRefusal(headers: http.IncomingHttpHeaders, allowOrigins?: readonly string[], allowHosts?: readonly string[]): string | null;
|
|
109
|
+
/** Start a multiplexing CDP endpoint. See the module header. */
|
|
110
|
+
export declare function serveMultiplex(options?: MultiplexOptions): Promise<MultiplexServer>;
|
|
111
|
+
//# sourceMappingURL=multiplex.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"multiplex.d.ts","sourceRoot":"","sources":["../src/multiplex.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
// serveMultiplex — one CDP endpoint, many identities.
|
|
2
|
+
//
|
|
3
|
+
// `serve()` starts ONE browser with one persona. This puts an HTTP/WebSocket front on a port and
|
|
4
|
+
// starts a separate browser per identity on demand, chosen by the connection URL:
|
|
5
|
+
//
|
|
6
|
+
// chromium.connectOverCDP("http://127.0.0.1:9222?fingerprint=acct-1&platform=windows")
|
|
7
|
+
// chromium.connectOverCDP("http://127.0.0.1:9222?fingerprint=acct-2&proxy=socks5://u:p@host:1080&geoip=true")
|
|
8
|
+
//
|
|
9
|
+
// Playwright/Puppeteer fetch `/json/version` (keeping the query), receive a WebSocket URL routed
|
|
10
|
+
// through this server (`/fingerprint/<id>/devtools/browser/<uuid>`), and connect. The same seed
|
|
11
|
+
// reuses the same browser; asking for a running identity with DIFFERENT parameters is a 409 (close it
|
|
12
|
+
// first). No seed and no parameters = one shared default browser.
|
|
13
|
+
//
|
|
14
|
+
// Endpoints: GET / (status) · GET /json/version · GET /json/list · GET /json ·
|
|
15
|
+
// POST /fingerprint/<id>/close · WS /fingerprint/<id>/devtools/* · WS /devtools/* (default).
|
|
16
|
+
// WebSocket routes only reach a RUNNING identity; browsers are started by the HTTP endpoints.
|
|
17
|
+
//
|
|
18
|
+
// SAFETY, deliberately stricter than the design it mirrors (whose equivalent server had an
|
|
19
|
+
// unauthenticated path-traversal and a browser-origin CSRF reported against it):
|
|
20
|
+
// * binds 127.0.0.1 by default; a non-loopback bind prints a warning, because anyone who can reach
|
|
21
|
+
// the port can start browsers and drive them;
|
|
22
|
+
// * seeds are validated against a strict pattern and profile directories are named by a HASH of
|
|
23
|
+
// the identity, so no request value ever becomes part of a filesystem path;
|
|
24
|
+
// * requests a web page could make are refused on every route: cross-site Sec-Fetch-Site, a browser
|
|
25
|
+
// Origin that is not loopback or allowed, or a Host that is not an IP literal / localhost / allowed
|
|
26
|
+
// (DNS rebinding);
|
|
27
|
+
// * the number of concurrent browsers is capped (maxBrowsers, default 16).
|
|
28
|
+
//
|
|
29
|
+
// WebSockets are relayed at the socket level (the upgrade request is rewritten and the two sockets
|
|
30
|
+
// piped), so no WebSocket library is needed and CDP traffic is not re-framed.
|
|
31
|
+
import http from "node:http";
|
|
32
|
+
import net from "node:net";
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { serve } from "./index.js";
|
|
36
|
+
import { toProxySpec } from "./net.js";
|
|
37
|
+
/** A seed usable in a URL path segment. Leading `_` is reserved for parameter-only identities. */
|
|
38
|
+
export const SEED_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
39
|
+
const NUMERIC = new Set([
|
|
40
|
+
"hardwareConcurrency", "deviceMemory", "screenWidth", "screenHeight", "availWidth", "availHeight",
|
|
41
|
+
"colorDepth", "devicePixelRatio", "maxTouchPoints", "storageQuota", "personaSchema",
|
|
42
|
+
]);
|
|
43
|
+
const BOOLEAN = new Set([
|
|
44
|
+
"lightStealth", "realGpuHost", "disableGpuFingerprint", "fingerprintNoise", "gpuStringSpoof", "canvasNoise",
|
|
45
|
+
"fingerprintVoices", "allowThirdPartyCookies", "transparentProxy", "socks5Udp",
|
|
46
|
+
]);
|
|
47
|
+
const STRING = new Set([
|
|
48
|
+
"platform", "platformVersion", "brand", "brandVersion", "gpuVendor", "gpuRenderer", "location",
|
|
49
|
+
"acceptLanguage", "webrtcIp", "webrtcMdns", "tlsProfile",
|
|
50
|
+
]);
|
|
51
|
+
const kebab = (k) => k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
52
|
+
/** Query parameter (kebab-case) -> launch option name, for every parameter a connection may set. */
|
|
53
|
+
export const QUERY_PARAMS = Object.fromEntries([...NUMERIC, ...BOOLEAN, ...STRING].map((k) => [kebab(k), k]));
|
|
54
|
+
const SPECIAL = new Set(["fingerprint", "timezone", "locale", "proxy", "geoip"]);
|
|
55
|
+
export class MultiplexRequestError extends Error {
|
|
56
|
+
status;
|
|
57
|
+
constructor(status, message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.status = status;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function parseBool(name, v) {
|
|
63
|
+
if (/^(1|true|yes|on)$/i.test(v))
|
|
64
|
+
return true;
|
|
65
|
+
if (/^(0|false|no|off)$/i.test(v))
|
|
66
|
+
return false;
|
|
67
|
+
throw new MultiplexRequestError(400, `query parameter '${name}' must be true or false`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Turn a connection's query string into an identity. Unknown parameters are rejected with 400
|
|
71
|
+
* rather than guessed at, so a typo never silently launches a different identity.
|
|
72
|
+
*/
|
|
73
|
+
export function parseConnectionIdentity(search) {
|
|
74
|
+
const options = {};
|
|
75
|
+
const params = {};
|
|
76
|
+
let seed;
|
|
77
|
+
for (const [name, value] of search) {
|
|
78
|
+
if (SPECIAL.has(name)) {
|
|
79
|
+
if (name === "fingerprint") {
|
|
80
|
+
if (!SEED_PATTERN.test(value)) {
|
|
81
|
+
throw new MultiplexRequestError(400, "fingerprint must match [A-Za-z0-9][A-Za-z0-9._-]{0,127}");
|
|
82
|
+
}
|
|
83
|
+
if (value === "default") {
|
|
84
|
+
throw new MultiplexRequestError(400, "fingerprint 'default' is reserved for the no-parameter browser; pick another seed");
|
|
85
|
+
}
|
|
86
|
+
seed = value;
|
|
87
|
+
options.fingerprint = value;
|
|
88
|
+
}
|
|
89
|
+
else if (name === "timezone") {
|
|
90
|
+
options.timezone = value;
|
|
91
|
+
}
|
|
92
|
+
else if (name === "locale") {
|
|
93
|
+
options.acceptLanguage = value;
|
|
94
|
+
}
|
|
95
|
+
else if (name === "geoip") {
|
|
96
|
+
options.geoip = parseBool(name, value);
|
|
97
|
+
}
|
|
98
|
+
else if (name === "proxy") {
|
|
99
|
+
let spec;
|
|
100
|
+
try {
|
|
101
|
+
spec = toProxySpec(value);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new MultiplexRequestError(400, "proxy must be a URL such as socks5://user:pass@host:1080");
|
|
105
|
+
}
|
|
106
|
+
if (!spec)
|
|
107
|
+
throw new MultiplexRequestError(400, "proxy is empty");
|
|
108
|
+
options.proxy = { server: spec.server, username: spec.username, password: spec.password };
|
|
109
|
+
params.proxy = spec.server + (spec.username ? " (with credentials)" : "");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
params[name] = value;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const key = QUERY_PARAMS[name];
|
|
116
|
+
if (!key) {
|
|
117
|
+
throw new MultiplexRequestError(400, `unknown query parameter '${name}'. Supported: ${["fingerprint", "timezone", "locale", "proxy", "geoip", ...Object.keys(QUERY_PARAMS)].join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
if (NUMERIC.has(key)) {
|
|
120
|
+
const n = value.trim() === "" ? NaN : Number(value);
|
|
121
|
+
if (!Number.isFinite(n))
|
|
122
|
+
throw new MultiplexRequestError(400, `query parameter '${name}' must be a number`);
|
|
123
|
+
options[key] = n;
|
|
124
|
+
}
|
|
125
|
+
else if (BOOLEAN.has(key)) {
|
|
126
|
+
options[key] = parseBool(name, value);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
options[key] = value;
|
|
130
|
+
}
|
|
131
|
+
params[name] = value;
|
|
132
|
+
}
|
|
133
|
+
if (seed)
|
|
134
|
+
return { id: seed, seed, options, params };
|
|
135
|
+
if (Object.keys(options).length === 0)
|
|
136
|
+
return { id: "default", options, params };
|
|
137
|
+
// Parameter-only identity: canonical form so ?a=1&b=2 and ?b=2&a=1 share one browser.
|
|
138
|
+
const canonical = JSON.stringify(Object.keys(options).sort().map((k) => [k, options[k]]));
|
|
139
|
+
return { id: `_p${createHash("sha256").update(canonical).digest("hex").slice(0, 16)}`, options, params };
|
|
140
|
+
}
|
|
141
|
+
/** Public ws(s):// base for URLs we hand back, honouring a reverse proxy's forwarded headers. */
|
|
142
|
+
export function publicWsBase(headers, fallbackHost) {
|
|
143
|
+
const first = (v) => (Array.isArray(v) ? v[0] : v)?.split(",")[0].trim();
|
|
144
|
+
const host = first(headers["x-forwarded-host"]) || first(headers.host) || fallbackHost;
|
|
145
|
+
const proto = (first(headers["x-forwarded-proto"]) || "").toLowerCase();
|
|
146
|
+
return `${proto === "https" || proto === "wss" ? "wss" : "ws"}://${host}`;
|
|
147
|
+
}
|
|
148
|
+
/** Rewrite a child's ws://127.0.0.1:<port>/devtools/... URL to route through the multiplexer. */
|
|
149
|
+
export function rewriteWsUrl(url, wsBase, id) {
|
|
150
|
+
const m = /^wss?:\/\/[^/]+(\/devtools\/.*)$/.exec(url);
|
|
151
|
+
if (!m)
|
|
152
|
+
return url;
|
|
153
|
+
const prefix = id === "default" ? "" : `/fingerprint/${encodeURIComponent(id)}`;
|
|
154
|
+
return `${wsBase}${prefix}${m[1]}`;
|
|
155
|
+
}
|
|
156
|
+
/** Origin allowed to open a CDP WebSocket: none (non-browser client), loopback, or explicitly listed. */
|
|
157
|
+
export function originAllowed(origin, extra = []) {
|
|
158
|
+
if (origin === undefined)
|
|
159
|
+
return true;
|
|
160
|
+
if (!origin || origin === "null")
|
|
161
|
+
return false;
|
|
162
|
+
if (extra.includes(origin))
|
|
163
|
+
return true;
|
|
164
|
+
try {
|
|
165
|
+
const h = new URL(origin).hostname.replace(/^\[|\]$/g, "");
|
|
166
|
+
return h === "localhost" || h === "127.0.0.1" || h === "::1";
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function idleTimeoutFromEnv() {
|
|
173
|
+
const n = Number((process.env.CLEARCOTE_SERVE_IDLE_TIMEOUT ?? "").trim());
|
|
174
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
175
|
+
}
|
|
176
|
+
/** Canonical form of an identity's launch options (proxy credentials included, never displayed). */
|
|
177
|
+
function optionsKey(options) {
|
|
178
|
+
return JSON.stringify(Object.keys(options).sort().map((k) => [k, options[k]]));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Host header allowed: an IP literal or "localhost" (the rule Chrome's own DevTools server uses
|
|
182
|
+
* against DNS rebinding), or a name in `extra` for a deployment behind a reverse proxy.
|
|
183
|
+
*/
|
|
184
|
+
export function hostAllowed(hostHeader, extra = []) {
|
|
185
|
+
if (hostHeader === undefined)
|
|
186
|
+
return true; // HTTP/1.0 client; nothing a browser page can omit
|
|
187
|
+
const h = hostHeader.trim().toLowerCase();
|
|
188
|
+
const name = h.startsWith("[") ? h.slice(1, h.indexOf("]")) : h.replace(/:\d+$/, "");
|
|
189
|
+
if (!name)
|
|
190
|
+
return false;
|
|
191
|
+
if (net.isIP(name) || name === "localhost")
|
|
192
|
+
return true;
|
|
193
|
+
return extra.some((e) => e.toLowerCase() === name || e.toLowerCase() === h);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Refuse requests a web page could have made: a cross-site/same-site fetch (Sec-Fetch-Site), a
|
|
197
|
+
* browser Origin that is not loopback or allowed, or a Host that is not ours (DNS rebinding).
|
|
198
|
+
* Playwright, Puppeteer and curl send none of these, so real clients are unaffected.
|
|
199
|
+
*/
|
|
200
|
+
export function requestRefusal(headers, allowOrigins = [], allowHosts = []) {
|
|
201
|
+
const origin = headers.origin;
|
|
202
|
+
const site = String(headers["sec-fetch-site"] ?? "").toLowerCase();
|
|
203
|
+
if ((site === "cross-site" || site === "same-site") && !(origin && allowOrigins.includes(origin))) {
|
|
204
|
+
return "Forbidden: cross-site request";
|
|
205
|
+
}
|
|
206
|
+
if (!originAllowed(origin, allowOrigins))
|
|
207
|
+
return "Forbidden origin";
|
|
208
|
+
if (!hostAllowed(headers.host, allowHosts))
|
|
209
|
+
return "Forbidden host";
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
/** Start a multiplexing CDP endpoint. See the module header. */
|
|
213
|
+
export async function serveMultiplex(options = {}) {
|
|
214
|
+
const { port = 9222, host = "127.0.0.1", idleTimeoutSec = idleTimeoutFromEnv(), dataDir, maxBrowsers = 16, allowOrigins = [], allowHosts = [], readyTimeoutMs = 30_000, startBrowser = (o) => serve(o), ...base } = options;
|
|
215
|
+
if (!Number.isInteger(maxBrowsers) || maxBrowsers < 1)
|
|
216
|
+
throw new Error("maxBrowsers must be a whole number >= 1");
|
|
217
|
+
const children = new Map();
|
|
218
|
+
const pending = new Map();
|
|
219
|
+
/** Ids whose browser is shutting down; a relaunch waits so two browsers never share a profile. */
|
|
220
|
+
const closingIds = new Map();
|
|
221
|
+
/** Ids closed while still launching: the launch closes its browser as soon as it is up. */
|
|
222
|
+
const cancelled = new Set();
|
|
223
|
+
const relaySockets = new Set();
|
|
224
|
+
let closing = false;
|
|
225
|
+
const loopback = ["127.0.0.1", "localhost", "::1"].includes(host);
|
|
226
|
+
if (!loopback && !base.quiet) {
|
|
227
|
+
process.stderr.write(`[clearcote] WARNING: serveMultiplex is bound to ${host}. Anyone who can reach this port can ` +
|
|
228
|
+
"start browsers and control them. Keep it on 127.0.0.1 or put authentication in front of it.\n");
|
|
229
|
+
}
|
|
230
|
+
function scheduleIdle(c) {
|
|
231
|
+
if (c.idleTimer)
|
|
232
|
+
clearTimeout(c.idleTimer);
|
|
233
|
+
c.idleTimer = undefined;
|
|
234
|
+
if (c.connections > 0) {
|
|
235
|
+
c.idleSince = undefined;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
c.idleSince = Date.now();
|
|
239
|
+
if (idleTimeoutSec > 0) {
|
|
240
|
+
c.idleTimer = setTimeout(() => { void closeIdentity(c.id); }, idleTimeoutSec * 1000);
|
|
241
|
+
c.idleTimer.unref?.();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const conflict = (id) => new MultiplexRequestError(409, `identity '${id}' is already running with different parameters; close it first (POST /fingerprint/${encodeURIComponent(id)}/close)`);
|
|
245
|
+
async function ensureChild(ident) {
|
|
246
|
+
if (closing)
|
|
247
|
+
throw new MultiplexRequestError(503, "shutting down");
|
|
248
|
+
const key = optionsKey(ident.options);
|
|
249
|
+
const existing = children.get(ident.id);
|
|
250
|
+
if (existing) {
|
|
251
|
+
// Silently handing back a browser started with other options (e.g. without the proxy this
|
|
252
|
+
// request asks for) would send this client's traffic somewhere it did not choose.
|
|
253
|
+
if (existing.key !== key)
|
|
254
|
+
throw conflict(ident.id);
|
|
255
|
+
scheduleIdle(existing); // a fresh /json/version means a client is about to connect
|
|
256
|
+
return existing;
|
|
257
|
+
}
|
|
258
|
+
const inflight = pending.get(ident.id);
|
|
259
|
+
if (inflight) {
|
|
260
|
+
if (inflight.key !== key)
|
|
261
|
+
throw conflict(ident.id);
|
|
262
|
+
return inflight.promise;
|
|
263
|
+
}
|
|
264
|
+
const stillClosing = closingIds.get(ident.id);
|
|
265
|
+
if (stillClosing) {
|
|
266
|
+
await stillClosing;
|
|
267
|
+
return ensureChild(ident);
|
|
268
|
+
}
|
|
269
|
+
if (children.size + pending.size >= maxBrowsers) {
|
|
270
|
+
throw new MultiplexRequestError(429, `maxBrowsers (${maxBrowsers}) reached; close an identity first`);
|
|
271
|
+
}
|
|
272
|
+
cancelled.delete(ident.id);
|
|
273
|
+
const promise = (async () => {
|
|
274
|
+
const srv = await startBrowser({
|
|
275
|
+
...base,
|
|
276
|
+
...ident.options,
|
|
277
|
+
host: "127.0.0.1",
|
|
278
|
+
port: undefined,
|
|
279
|
+
quiet: true,
|
|
280
|
+
readyTimeoutMs,
|
|
281
|
+
// Profile directory named by a hash of the identity — never by a request value.
|
|
282
|
+
userDataDir: dataDir ? join(dataDir, createHash("sha256").update(`clearcote:${ident.id}`).digest("hex").slice(0, 24)) : undefined,
|
|
283
|
+
});
|
|
284
|
+
if (closing || cancelled.has(ident.id)) {
|
|
285
|
+
cancelled.delete(ident.id);
|
|
286
|
+
await srv.close().catch(() => { });
|
|
287
|
+
throw new MultiplexRequestError(503, closing ? "shutting down" : `identity '${ident.id}' was closed while starting`);
|
|
288
|
+
}
|
|
289
|
+
const child = { id: ident.id, seed: ident.seed, key, params: ident.params, srv, connections: 0, startedAt: Date.now() };
|
|
290
|
+
children.set(ident.id, child);
|
|
291
|
+
scheduleIdle(child);
|
|
292
|
+
return child;
|
|
293
|
+
})();
|
|
294
|
+
pending.set(ident.id, { key, promise });
|
|
295
|
+
try {
|
|
296
|
+
return await promise;
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
pending.delete(ident.id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async function closeIdentity(id) {
|
|
303
|
+
const c = children.get(id);
|
|
304
|
+
if (!c) {
|
|
305
|
+
const inflight = pending.get(id);
|
|
306
|
+
if (!inflight)
|
|
307
|
+
return false;
|
|
308
|
+
cancelled.add(id);
|
|
309
|
+
await inflight.promise.catch(() => { });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
children.delete(id);
|
|
313
|
+
if (c.idleTimer)
|
|
314
|
+
clearTimeout(c.idleTimer);
|
|
315
|
+
const done = c.srv.close().catch(() => { }).finally(() => {
|
|
316
|
+
if (closingIds.get(id) === done)
|
|
317
|
+
closingIds.delete(id);
|
|
318
|
+
});
|
|
319
|
+
closingIds.set(id, done);
|
|
320
|
+
await done;
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
function status() {
|
|
324
|
+
return {
|
|
325
|
+
status: "ok",
|
|
326
|
+
active: children.size,
|
|
327
|
+
maxBrowsers,
|
|
328
|
+
idleTimeoutSec,
|
|
329
|
+
processes: [...children.values()].map((c) => ({
|
|
330
|
+
id: c.id,
|
|
331
|
+
...(c.seed ? { seed: c.seed } : {}),
|
|
332
|
+
pid: c.srv.pid,
|
|
333
|
+
port: c.srv.port,
|
|
334
|
+
connections: c.connections,
|
|
335
|
+
startedAt: new Date(c.startedAt).toISOString(),
|
|
336
|
+
...(c.idleSince ? { idleSince: new Date(c.idleSince).toISOString() } : {}),
|
|
337
|
+
params: c.params,
|
|
338
|
+
})),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function sendJson(res, code, body) {
|
|
342
|
+
const text = JSON.stringify(body);
|
|
343
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(text) });
|
|
344
|
+
res.end(text);
|
|
345
|
+
}
|
|
346
|
+
const server = http.createServer(async (req, res) => {
|
|
347
|
+
try {
|
|
348
|
+
const refusal = requestRefusal(req.headers, allowOrigins, allowHosts);
|
|
349
|
+
if (refusal)
|
|
350
|
+
return sendJson(res, 403, { error: refusal });
|
|
351
|
+
const u = new URL(req.url ?? "/", "http://mux.invalid");
|
|
352
|
+
const path = u.pathname.replace(/\/+$/, "") || "/";
|
|
353
|
+
const wsBase = publicWsBase(req.headers, `${host}:${actualPort()}`);
|
|
354
|
+
if (req.method === "GET" && path === "/")
|
|
355
|
+
return sendJson(res, 200, status());
|
|
356
|
+
const close = /^\/fingerprint\/([^/]+)\/close$/.exec(path);
|
|
357
|
+
if (close) {
|
|
358
|
+
if (req.method !== "POST")
|
|
359
|
+
return sendJson(res, 405, { error: "use POST" });
|
|
360
|
+
const id = decodeURIComponent(close[1]);
|
|
361
|
+
return sendJson(res, 200, { id, terminated: await closeIdentity(id) });
|
|
362
|
+
}
|
|
363
|
+
if (req.method === "GET" && (path === "/json/version" || path === "/json/list" || path === "/json")) {
|
|
364
|
+
const ident = parseConnectionIdentity(u.searchParams);
|
|
365
|
+
const child = await ensureChild(ident);
|
|
366
|
+
const upstream = await fetch(`http://127.0.0.1:${child.srv.port}${path === "/json" ? "/json/list" : path}`, {
|
|
367
|
+
signal: AbortSignal.timeout(10_000),
|
|
368
|
+
});
|
|
369
|
+
const data = (await upstream.json());
|
|
370
|
+
const fix = (o) => {
|
|
371
|
+
if (typeof o.webSocketDebuggerUrl === "string")
|
|
372
|
+
o.webSocketDebuggerUrl = rewriteWsUrl(o.webSocketDebuggerUrl, wsBase, child.id);
|
|
373
|
+
if (typeof o.devtoolsFrontendUrl === "string") {
|
|
374
|
+
// "/devtools/inspector.html?ws=127.0.0.1:<childport>/devtools/page/<id>" -> via the multiplexer
|
|
375
|
+
const hostPart = wsBase.replace(/^wss?:\/\//, "");
|
|
376
|
+
const prefix = child.id === "default" ? "" : `/fingerprint/${encodeURIComponent(child.id)}`;
|
|
377
|
+
// Function replacer: a forwarded host must never be read as a `$&`-style pattern.
|
|
378
|
+
o.devtoolsFrontendUrl = o.devtoolsFrontendUrl.replace(/([?&]wss?=)[^/&]+(\/devtools\/)/, (_m, a, b) => `${a}${hostPart}${prefix}${b}`);
|
|
379
|
+
}
|
|
380
|
+
return o;
|
|
381
|
+
};
|
|
382
|
+
return sendJson(res, 200, Array.isArray(data) ? data.map(fix) : fix(data));
|
|
383
|
+
}
|
|
384
|
+
return sendJson(res, 404, { error: "not found" });
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
const code = e instanceof MultiplexRequestError ? e.status : 502;
|
|
388
|
+
if (res.headersSent) {
|
|
389
|
+
res.destroy();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
return sendJson(res, code, { error: e.message });
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
server.on("upgrade", (req, socket, head) => {
|
|
396
|
+
// Node removes its own error handler from an upgraded socket: without this, one client reset
|
|
397
|
+
// is an uncaught exception that takes down the server and every browser it holds.
|
|
398
|
+
socket.on("error", () => socket.destroy());
|
|
399
|
+
const reject = (code, msg) => {
|
|
400
|
+
if (socket.destroyed)
|
|
401
|
+
return;
|
|
402
|
+
socket.end(`HTTP/1.1 ${code} ${msg}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n${msg}\n`);
|
|
403
|
+
};
|
|
404
|
+
try {
|
|
405
|
+
const refusal = requestRefusal(req.headers, allowOrigins, allowHosts);
|
|
406
|
+
if (refusal)
|
|
407
|
+
return reject(403, refusal);
|
|
408
|
+
if (String(req.headers.upgrade ?? "").toLowerCase() !== "websocket")
|
|
409
|
+
return reject(400, "Only WebSocket upgrades are relayed");
|
|
410
|
+
const u = new URL(req.url ?? "/", "http://mux.invalid");
|
|
411
|
+
let id = "default";
|
|
412
|
+
let rest = u.pathname;
|
|
413
|
+
const m = /^\/fingerprint\/([^/]+)(\/devtools\/.*)$/.exec(u.pathname);
|
|
414
|
+
if (m) {
|
|
415
|
+
id = decodeURIComponent(m[1]);
|
|
416
|
+
rest = m[2];
|
|
417
|
+
}
|
|
418
|
+
else if (!u.pathname.startsWith("/devtools/")) {
|
|
419
|
+
return reject(404, "Not Found");
|
|
420
|
+
}
|
|
421
|
+
// The WebSocket route never starts a browser. A path carries only the id, not the proxy or
|
|
422
|
+
// other options the identity was created with, so a browser started from it could send the
|
|
423
|
+
// client's traffic direct; and its DevTools GUID would not match the URL anyway.
|
|
424
|
+
const c = children.get(id);
|
|
425
|
+
if (!c)
|
|
426
|
+
return reject(404, `identity '${id}' is not running; connect via http://host:port/?fingerprint=... first`);
|
|
427
|
+
const upstream = net.connect(c.srv.port, "127.0.0.1");
|
|
428
|
+
let relaying = false;
|
|
429
|
+
upstream.once("connect", () => {
|
|
430
|
+
if (socket.destroyed || children.get(c.id) !== c) {
|
|
431
|
+
upstream.destroy();
|
|
432
|
+
if (!socket.destroyed)
|
|
433
|
+
reject(502, "Browser closed");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
relaying = true;
|
|
437
|
+
let reqHead = `GET ${rest}${u.search} HTTP/1.1\r\n`;
|
|
438
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
439
|
+
const k = req.rawHeaders[i];
|
|
440
|
+
const lk = k.toLowerCase();
|
|
441
|
+
// The origin was checked above; the browser's own --remote-allow-origins check would
|
|
442
|
+
// reject a forwarded browser Origin, and Host must name the child.
|
|
443
|
+
if (lk === "host" || lk === "origin")
|
|
444
|
+
continue;
|
|
445
|
+
reqHead += `${k}: ${req.rawHeaders[i + 1]}\r\n`;
|
|
446
|
+
}
|
|
447
|
+
reqHead += `Host: 127.0.0.1:${c.srv.port}\r\n\r\n`;
|
|
448
|
+
upstream.write(reqHead);
|
|
449
|
+
if (head.length)
|
|
450
|
+
upstream.write(head);
|
|
451
|
+
c.connections++;
|
|
452
|
+
scheduleIdle(c);
|
|
453
|
+
let released = false;
|
|
454
|
+
const release = () => {
|
|
455
|
+
if (released)
|
|
456
|
+
return;
|
|
457
|
+
released = true;
|
|
458
|
+
c.connections = Math.max(0, c.connections - 1);
|
|
459
|
+
if (children.get(c.id) === c)
|
|
460
|
+
scheduleIdle(c);
|
|
461
|
+
};
|
|
462
|
+
relaySockets.add(socket);
|
|
463
|
+
relaySockets.add(upstream);
|
|
464
|
+
socket.pipe(upstream).pipe(socket);
|
|
465
|
+
// Tear down BOTH sides as soon as EITHER ends. http.Server sockets are half-open
|
|
466
|
+
// (allowHalfOpen), so relying on "close" alone left a finished CDP session holding the
|
|
467
|
+
// relay open forever: its connection never counted as gone and idle close never ran.
|
|
468
|
+
const teardown = () => {
|
|
469
|
+
socket.destroy();
|
|
470
|
+
upstream.destroy();
|
|
471
|
+
relaySockets.delete(socket);
|
|
472
|
+
relaySockets.delete(upstream);
|
|
473
|
+
release();
|
|
474
|
+
};
|
|
475
|
+
socket.once("end", teardown);
|
|
476
|
+
upstream.once("end", teardown);
|
|
477
|
+
socket.once("close", teardown);
|
|
478
|
+
upstream.once("close", teardown);
|
|
479
|
+
});
|
|
480
|
+
// Once relaying, the stream belongs to the WebSocket: never write an HTTP response into it.
|
|
481
|
+
upstream.once("error", () => { if (relaying)
|
|
482
|
+
socket.destroy();
|
|
483
|
+
else
|
|
484
|
+
reject(502, "Browser unreachable"); });
|
|
485
|
+
socket.once("close", () => { if (!relaying)
|
|
486
|
+
upstream.destroy(); });
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
const code = e instanceof MultiplexRequestError ? e.status : 502;
|
|
490
|
+
reject(code, e.message.replace(/[\r\n]/g, " "));
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
await new Promise((resolve, reject) => {
|
|
494
|
+
server.once("error", reject);
|
|
495
|
+
server.listen(port, host, () => resolve());
|
|
496
|
+
});
|
|
497
|
+
function actualPort() {
|
|
498
|
+
const a = server.address();
|
|
499
|
+
return typeof a === "object" && a ? a.port : port;
|
|
500
|
+
}
|
|
501
|
+
const boundPort = actualPort();
|
|
502
|
+
const urlHost = host.includes(":") ? `[${host}]` : host;
|
|
503
|
+
const handle = {
|
|
504
|
+
host,
|
|
505
|
+
port: boundPort,
|
|
506
|
+
url: `http://${urlHost}:${boundPort}`,
|
|
507
|
+
status,
|
|
508
|
+
closeIdentity,
|
|
509
|
+
async close() {
|
|
510
|
+
if (closing)
|
|
511
|
+
return;
|
|
512
|
+
closing = true;
|
|
513
|
+
const stopped = new Promise((resolve) => server.close(() => resolve()));
|
|
514
|
+
// server.close() waits for every open connection: keep-alive HTTP clients and relayed CDP
|
|
515
|
+
// sessions would hold it open indefinitely. Shutting down means ending them.
|
|
516
|
+
server.closeAllConnections?.();
|
|
517
|
+
for (const s of relaySockets)
|
|
518
|
+
s.destroy();
|
|
519
|
+
relaySockets.clear();
|
|
520
|
+
await stopped;
|
|
521
|
+
// Launches still in flight see `closing` and close their own browser; wait for them, then
|
|
522
|
+
// close the running ones and any close already under way.
|
|
523
|
+
await Promise.allSettled([...pending.values()].map((p) => p.promise));
|
|
524
|
+
await Promise.all([...children.keys()].map((id) => closeIdentity(id)));
|
|
525
|
+
await Promise.allSettled([...closingIds.values()]);
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
process.once("exit", () => { for (const c of children.values())
|
|
529
|
+
void c.srv.close(); });
|
|
530
|
+
if (!base.quiet) {
|
|
531
|
+
process.stderr.write(`[clearcote] multiplexed CDP endpoint ready: ${handle.url}\n` +
|
|
532
|
+
` connectOverCDP("${handle.url}?fingerprint=<seed>") · status: GET ${handle.url}/\n`);
|
|
533
|
+
}
|
|
534
|
+
return handle;
|
|
535
|
+
}
|
|
536
|
+
//# sourceMappingURL=multiplex.js.map
|