clearcote 0.33.0 → 0.35.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/dist/cloud.js ADDED
@@ -0,0 +1,942 @@
1
+ // Clearcote Cloud: hosted browsers, agent runs, profiles, recordings, events, hand-off and webhooks.
2
+ //
3
+ // The same SDK runs a browser on this machine or on Clearcote's servers, and one flag picks which:
4
+ //
5
+ // import { launch } from "clearcote";
6
+ // const browser = await launch({ cloud: true, country: "us", humanize: true }); // or CLEARCOTE_CLOUD=1
7
+ // const page = await browser.newPage(); // the same Playwright Browser
8
+ // await browser.close(); // disconnects, ends the session
9
+ //
10
+ // Everything else the hosted API does is on `Cloud`:
11
+ //
12
+ // import { Cloud } from "clearcote";
13
+ // const cloud = new Cloud(); // CLEARCOTE_API_KEY; CLEARCOTE_API_URL points it at another server
14
+ // const run = await cloud.runs.create("Find the price of the cheapest plan", { url: "https://example.com" });
15
+ // console.log(run.status, run.result?.output);
16
+ //
17
+ // Conventions:
18
+ // * No dependencies: global fetch for HTTP, node:crypto for webhook signatures.
19
+ // * Every method resolves to the parsed JSON body of the endpoint it calls (null for an empty body).
20
+ // * Every non-2xx answer rejects with a CloudError carrying the server's own message and code.
21
+ // * Options use the API's own names (proxySession, timeoutSec), passed through unchanged; the API
22
+ // validates the values. Durations of the SDK's own waits (timeout, poll) are in SECONDS, as in Python.
23
+ //
24
+ // Mirrors the Python SDK's clearcote/cloud.py: same resources, same defaults, same errors, same CLI output.
25
+ import { createHmac, timingSafeEqual } from "node:crypto";
26
+ import { createWriteStream, existsSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
27
+ import * as readline from "node:readline";
28
+ import { Readable } from "node:stream";
29
+ import { pipeline } from "node:stream/promises";
30
+ import { chromium } from "playwright-core";
31
+ import { AGENT_KEYS } from "./agent.js";
32
+ import { FINGERPRINT_KEYS } from "./fingerprint.js";
33
+ import { installHumanize, installHumanizeOnContext } from "./humanize.js";
34
+ import { toProxySpec } from "./net.js";
35
+ export const DEFAULT_API_URL = "https://www.clearcotelabs.com";
36
+ /** A run is finished in exactly these states. "waiting_for_human" is NOT one of them: the run is
37
+ * paused on a hand-off and resumes once the person marks it done (or the hand-off times out). */
38
+ export const TERMINAL_RUN_STATUSES = ["succeeded", "failed", "cancelled", "expired"];
39
+ const TRUTHY = ["1", "true", "yes"];
40
+ // Errors worth another poll rather than giving up on a long wait: no connection, rate limited, or a
41
+ // gateway in front of the API restarting. Anything else (401, 404, ...) will not fix itself.
42
+ const TRANSIENT = [0, 429, 502, 503, 504];
43
+ const MAX_TRANSIENT = 4;
44
+ export const NO_API_KEY = "no Clearcote API key: pass apiKey or set CLEARCOTE_API_KEY (create a key in the Clearcote dashboard)";
45
+ // Plain http:// is accepted only for these hosts (a local dev control plane): anywhere else the API key
46
+ // would cross the network unencrypted.
47
+ const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"];
48
+ /** `base` (already stripped of trailing slashes) if it is an https:// URL, or an http:// URL of this
49
+ * machine; throws naming the reason otherwise. */
50
+ function checkBaseUrl(base) {
51
+ let url = null;
52
+ try {
53
+ url = new URL(base);
54
+ }
55
+ catch { /* reported below */ }
56
+ const scheme = url?.protocol.replace(/:$/, "").toLowerCase() ?? "";
57
+ const host = (url?.hostname ?? "").toLowerCase().replace(/^\[(.*)\]$/, "$1");
58
+ if (!url || !/^https?:\/\//i.test(base) || !["https", "http"].includes(scheme) || !host) {
59
+ throw new Error(`the API URL must start with https:// (got ${JSON.stringify(base)})`);
60
+ }
61
+ if (scheme === "http" && !LOOPBACK_HOSTS.includes(host)) {
62
+ throw new Error(`the API URL must use https:// (got http://${host}): over plain http the API key would travel ` +
63
+ "unencrypted. http:// is only accepted for this machine (127.0.0.1, ::1, localhost)");
64
+ }
65
+ return base;
66
+ }
67
+ const SDK_VERSION = (() => {
68
+ try {
69
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
70
+ }
71
+ catch {
72
+ return "unknown";
73
+ }
74
+ })();
75
+ const USER_AGENT = `clearcote-sdk-node/${SDK_VERSION}`;
76
+ // ── errors ─────────────────────────────────────────────────────────────────────────────────────────
77
+ /**
78
+ * A request the hosted API refused or could not answer. `status` is the HTTP status (0 when the
79
+ * server could not be reached), `code` the API's machine-readable code when it sent one
80
+ * ("PROFILE_IN_USE", "NOT_READY", ...; otherwise null) and `message` the API's own explanation.
81
+ */
82
+ export class CloudError extends Error {
83
+ status;
84
+ code;
85
+ constructor(status, code, message) {
86
+ super(message);
87
+ this.name = "CloudError";
88
+ this.status = status;
89
+ this.code = code;
90
+ }
91
+ }
92
+ /** A wait ran out of time. The run (or hand-off) carries on on the server; `last` holds the last
93
+ * view the SDK read, so its id and status are at hand. */
94
+ export class CloudTimeoutError extends Error {
95
+ last;
96
+ constructor(message, last) {
97
+ super(message);
98
+ this.name = "CloudTimeoutError";
99
+ this.last = last;
100
+ }
101
+ }
102
+ // ── local or cloud ─────────────────────────────────────────────────────────────────────────────────
103
+ /**
104
+ * Whether a launch should go to the cloud. `cloud` unset defers to the environment:
105
+ * `CLEARCOTE_CLOUD` set to 1, true or yes means cloud, anything else local. An explicit true/false
106
+ * (or a {@link Cloud} client, which means cloud) always wins over the environment.
107
+ */
108
+ export function cloudRequested(cloud) {
109
+ if (cloud === undefined || cloud === null) {
110
+ return TRUTHY.includes((process.env.CLEARCOTE_CLOUD ?? "").trim().toLowerCase());
111
+ }
112
+ // "0"/"false" from a config file must not mean cloud
113
+ if (typeof cloud === "string")
114
+ return TRUTHY.includes(cloud.trim().toLowerCase());
115
+ return Boolean(cloud);
116
+ }
117
+ /** Option name -> POST /api/v1/browsers field. The Node names ARE the API names (plus the local
118
+ * launch's `acceptLanguage` as an alias of `locale`). Values pass through as given; the API validates
119
+ * them, so the SDK keeps no second copy of the server's rules that could drift. */
120
+ export const SESSION_FIELDS = {
121
+ fingerprint: "fingerprint",
122
+ identity: "identity",
123
+ platform: "platform",
124
+ brand: "brand",
125
+ timezone: "timezone",
126
+ locale: "locale",
127
+ acceptLanguage: "locale",
128
+ geoip: "geoip",
129
+ headless: "headless",
130
+ lightStealth: "lightStealth",
131
+ proxy: "proxy",
132
+ country: "country",
133
+ state: "state",
134
+ city: "city",
135
+ proxySession: "proxySession",
136
+ timeoutSec: "timeoutSec",
137
+ idleTimeoutSec: "idleTimeoutSec",
138
+ maxGb: "maxGb",
139
+ version: "version",
140
+ profile: "profile",
141
+ url: "url",
142
+ adblock: "adblock",
143
+ keepAlive: "keepAlive",
144
+ record: "record",
145
+ note: "note",
146
+ worker: "worker",
147
+ };
148
+ /** Run-only fields of POST /api/v1/runs (task, url, schema and secrets are named options). */
149
+ export const RUN_FIELDS = {
150
+ maxSteps: "maxSteps",
151
+ handoff: "handoff",
152
+ handoffTimeoutSec: "handoffTimeoutSec",
153
+ };
154
+ // Options a cloud launch() handles on THIS side and never sends: input humanization runs in the SDK
155
+ // exactly as for a local browser, timeout/slowMo are Playwright's connectOverCDP options (in its
156
+ // units, milliseconds), and apiKey/apiUrl pick the account and server.
157
+ const CONNECT_OPTIONS = ["timeout", "slowMo"];
158
+ export const SDK_SIDE_OPTIONS = ["humanize", "showCursor", "quiet", "apiKey", "apiUrl", ...CONNECT_OPTIONS];
159
+ /** Everything launch() accepts that only makes sense for a browser on this machine: derived from
160
+ * the local launch surface (persona switches, agent switches, binary, licence, profile directory and
161
+ * Playwright launch options), so an option added there is classified here too. */
162
+ export const LOCAL_ONLY_OPTIONS = [
163
+ ...FINGERPRINT_KEYS.filter((k) => !Object.prototype.hasOwnProperty.call(SESSION_FIELDS, k)),
164
+ ...AGENT_KEYS,
165
+ "executablePath", "args", "ignoreDefaultArgs", "userDataDir", "ephemeralProfile", "extensions",
166
+ "portableProfile", "encryptionKey", "disablePrivacySandbox", "socks5Udp", "shaderDialect", "widevine",
167
+ "profileSelect", "cacheDir", "autoUpdate", "releaseChannel", "allowThirdPartyCookies", "transparentProxy",
168
+ "licenseKey", "licenseApiBase", "licenseThroughProxy", "env", "devtools", "downloadsPath", "tracesDir",
169
+ "chromiumSandbox", "channel", "handleSIGINT", "handleSIGTERM", "handleSIGHUP", "firefoxUserPrefs",
170
+ "artifactsDir", "logger",
171
+ ];
172
+ const USER_DATA_DIR_MSG = "userDataDir is not available for cloud browsers: a cloud browser keeps its cookies in a cloud " +
173
+ "profile, so pass profile: \"name\" instead (cloud.profiles.sync can fill one from a local profile directory)";
174
+ function notAvailable(name, run) {
175
+ if (name === "userDataDir")
176
+ return new Error(USER_DATA_DIR_MSG);
177
+ return new Error(`${name} is not available for cloud ${run ? "runs" : "browsers"}`);
178
+ }
179
+ const PROXY_SHAPE = 'proxy must be "managed", a proxy URL, or {server, username, password}';
180
+ function cloudProxy(value) {
181
+ if (typeof value === "string" && ["managed", "direct"].includes(value.trim()))
182
+ return value.trim();
183
+ if (value && typeof value === "object") {
184
+ const extra = Object.keys(value).filter((k) => value[k] != null && !["server", "username", "password"].includes(k)).sort();
185
+ if (extra.length)
186
+ throw new Error(`proxy.${extra[0]} is not available for cloud browsers`);
187
+ }
188
+ else if (typeof value !== "string") {
189
+ throw new Error(PROXY_SHAPE);
190
+ }
191
+ let spec;
192
+ try {
193
+ spec = toProxySpec(value);
194
+ }
195
+ catch {
196
+ throw new Error(PROXY_SHAPE);
197
+ }
198
+ if (!spec)
199
+ throw new Error(PROXY_SHAPE);
200
+ return spec;
201
+ }
202
+ function cloudProfile(value) {
203
+ if (value && typeof value === "object") {
204
+ if (Object.getPrototypeOf(value) !== Object.prototype) {
205
+ throw new Error("a saved local Profile cannot be used for a cloud browser; pass the name of a cloud profile (see cloud.profiles)");
206
+ }
207
+ const extra = Object.keys(value).filter((k) => !["name", "persist"].includes(k)).sort();
208
+ if (extra.length)
209
+ throw new Error(`profile.${extra[0]} is not a cloud profile field (use name and persist)`);
210
+ }
211
+ else if (typeof value !== "string") {
212
+ throw new Error("a saved local Profile cannot be used for a cloud browser; pass the name of a cloud profile (see cloud.profiles)");
213
+ }
214
+ const name = typeof value === "string" ? value : value.name;
215
+ if (name === "auto") {
216
+ throw new Error('profile "auto" picks a local persona; for a stable cloud device pass identity, and for cookies a cloud profile name');
217
+ }
218
+ return typeof value === "string" ? value : { ...value };
219
+ }
220
+ /** Map launch()/create() options to the API's JSON body. Throws naming the first option a cloud
221
+ * browser (or run) cannot take. undefined/null values are left out, so an optional setting can be
222
+ * passed through unconditionally. */
223
+ export function sessionBody(options, run = false) {
224
+ const fields = run ? { ...SESSION_FIELDS, ...RUN_FIELDS } : { ...SESSION_FIELDS };
225
+ if (options.locale != null && options.acceptLanguage != null)
226
+ throw new Error("pass locale or acceptLanguage, not both");
227
+ const body = {};
228
+ for (const [key, raw] of Object.entries(options)) {
229
+ if (!Object.prototype.hasOwnProperty.call(fields, key))
230
+ throw notAvailable(key, run);
231
+ if (raw === undefined || raw === null)
232
+ continue;
233
+ let value = raw;
234
+ if (key === "proxy")
235
+ value = cloudProxy(raw);
236
+ else if (key === "profile")
237
+ value = cloudProfile(raw);
238
+ body[fields[key]] = value;
239
+ }
240
+ return body;
241
+ }
242
+ /** The POST /api/v1/runs body: the task, where it starts, what to return, and the browser. */
243
+ export function runBody(task, options = {}) {
244
+ const { url, schema, secrets, wait: _w, timeout: _t, poll: _p, onUpdate: _o, ...browser } = options;
245
+ return {
246
+ task,
247
+ ...(url != null ? { url } : {}),
248
+ ...(schema != null ? { schema } : {}),
249
+ ...(secrets != null ? { secrets } : {}),
250
+ ...sessionBody(browser, true),
251
+ };
252
+ }
253
+ // ── HTTP ───────────────────────────────────────────────────────────────────────────────────────────
254
+ function apiError(status, text, reason = "") {
255
+ let data = null;
256
+ try {
257
+ data = text ? JSON.parse(text) : null;
258
+ }
259
+ catch { /* not JSON */ }
260
+ if (data && typeof data === "object" && data.error && typeof data.error === "object") {
261
+ data = { ...data.error, code: data.error.code ?? data.code }; // { error: { message, code } }
262
+ }
263
+ if (data && typeof data === "object" && (data.error || data.message)) {
264
+ return new CloudError(status, typeof data.code === "string" ? data.code : null, String(data.error || data.message));
265
+ }
266
+ const t = (text || "").trim();
267
+ return new CloudError(status, null, t ? t.slice(0, 300) : `HTTP ${status} ${reason}`.trim());
268
+ }
269
+ function parseJson(status, text) {
270
+ if (!text || !text.trim())
271
+ return null;
272
+ try {
273
+ return JSON.parse(text);
274
+ }
275
+ catch {
276
+ throw new CloudError(status, null, "the API answered with something that is not JSON");
277
+ }
278
+ }
279
+ /** One URL path segment (ids and profile names are user input). */
280
+ const q = (segment) => encodeURIComponent(String(segment));
281
+ function compact(o) {
282
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null));
283
+ }
284
+ function causeOf(e) {
285
+ const c = e?.cause;
286
+ return c?.message || c?.code || e?.message || String(e);
287
+ }
288
+ class Http {
289
+ baseUrl;
290
+ timeoutMs;
291
+ #key; // a true private field: never printed by console.log / util.inspect
292
+ constructor(key, baseUrl, timeoutMs) {
293
+ this.baseUrl = baseUrl;
294
+ this.timeoutMs = timeoutMs;
295
+ this.#key = key;
296
+ }
297
+ url(path, query) {
298
+ const qs = new URLSearchParams();
299
+ for (const [k, v] of Object.entries(query ?? {})) {
300
+ if (v === undefined || v === null)
301
+ continue;
302
+ qs.append(k, v === true ? "1" : v === false ? "0" : String(v));
303
+ }
304
+ const s = qs.toString();
305
+ return this.baseUrl + path + (s ? `?${s}` : "");
306
+ }
307
+ /** { status, location, text }. 3xx comes back as is (never followed: the recording endpoint
308
+ * redirects to presigned storage, which must not receive the API key); 4xx/5xx reject. */
309
+ async raw(method, path, body, query) {
310
+ const headers = { authorization: `Bearer ${this.#key}`, accept: "application/json", "User-Agent": USER_AGENT };
311
+ if (body !== undefined)
312
+ headers["content-type"] = "application/json";
313
+ let res;
314
+ try {
315
+ res = await fetch(this.url(path, query), {
316
+ method,
317
+ headers,
318
+ body: body === undefined ? undefined : JSON.stringify(body),
319
+ redirect: "manual",
320
+ signal: AbortSignal.timeout(this.timeoutMs),
321
+ });
322
+ }
323
+ catch (e) {
324
+ throw new CloudError(0, "NETWORK", `could not reach ${this.baseUrl}: ${causeOf(e)}`);
325
+ }
326
+ let text;
327
+ try {
328
+ text = await res.text();
329
+ }
330
+ catch (e) {
331
+ // the connection was cut (or the timeout hit) while the answer was being read
332
+ throw new CloudError(0, "NETWORK", `could not reach ${this.baseUrl}: ${causeOf(e)}`);
333
+ }
334
+ if (res.status >= 300 && res.status < 400)
335
+ return { status: res.status, location: res.headers.get("location"), text };
336
+ if (!res.ok)
337
+ throw apiError(res.status, text, res.statusText);
338
+ return { status: res.status, location: null, text };
339
+ }
340
+ async call(method, path, body, query) {
341
+ const r = await this.raw(method, path, body, query);
342
+ if (r.status >= 300 && r.status < 400)
343
+ throw new CloudError(r.status, null, `unexpected redirect to ${r.location}`);
344
+ return parseJson(r.status, r.text);
345
+ }
346
+ }
347
+ // ── polling ────────────────────────────────────────────────────────────────────────────────────────
348
+ /** Default report when a run pauses for a person: without it a waiting run just looks stuck. */
349
+ export function announceHandoff(view) {
350
+ const h = view?.handoff ?? {};
351
+ process.stderr.write(`[clearcote] run ${view?.id} is waiting for a human${h.reason ? ` (${h.reason})` : ""}: ` +
352
+ `${h.liveUrl || "open it in the Clearcote dashboard"}\n`);
353
+ }
354
+ const sleep = (s) => new Promise((r) => setTimeout(r, Math.max(0, s * 1000)));
355
+ /** The decisions a wait makes on every poll (shared shape with the Python SDK): has it finished,
356
+ * should the caller hear about it, is an error worth another try, how long to sleep. */
357
+ class Watch {
358
+ what;
359
+ timeout;
360
+ done;
361
+ key;
362
+ deadline;
363
+ lastKey = "\u0000";
364
+ failures = 0;
365
+ last = null;
366
+ constructor(what, timeout, done, key) {
367
+ this.what = what;
368
+ this.timeout = timeout;
369
+ this.done = done;
370
+ this.key = key;
371
+ this.deadline = timeout == null ? null : Date.now() + timeout * 1000;
372
+ }
373
+ seen(view) {
374
+ this.failures = 0;
375
+ this.last = view;
376
+ const k = this.key(view);
377
+ const changed = k !== this.lastKey;
378
+ this.lastKey = k;
379
+ return { done: this.done(view), changed };
380
+ }
381
+ retry(err) {
382
+ if (err instanceof CloudError && TRANSIENT.includes(err.status) && this.failures < MAX_TRANSIENT) {
383
+ this.failures += 1;
384
+ return true;
385
+ }
386
+ return false;
387
+ }
388
+ pause(poll) {
389
+ if (this.deadline === null)
390
+ return poll;
391
+ const left = (this.deadline - Date.now()) / 1000;
392
+ if (left <= 0) {
393
+ const status = this.last?.status;
394
+ throw new CloudTimeoutError(`${this.what} is still ${status || "not finished"} after ${this.timeout}s; it carries on on the server`, this.last);
395
+ }
396
+ return Math.min(poll, left);
397
+ }
398
+ }
399
+ const runWatch = (id, timeout) => new Watch(`run ${id}`, timeout, (v) => TERMINAL_RUN_STATUSES.includes(v?.status), (v) => JSON.stringify([v?.status ?? null, v?.handoff?.state ?? null, v?.handoff?.since ?? null]));
400
+ /** A session with no hand-off at all reads as "not waiting", so waitHandoff would resolve at once as if
401
+ * a person had finished. On the first read that is a mistake (no handoff() was requested): say so
402
+ * instead. A done or timed-out hand-off still resolves. */
403
+ function requireHandoff(id, view, first) {
404
+ if (first && !view?.handoff) {
405
+ throw new CloudError(200, "NO_HANDOFF", `no hand-off was requested for session ${id} (request one with browsers.handoff first)`);
406
+ }
407
+ }
408
+ const handoffWatch = (id, timeout) => new Watch(`the hand-off of ${id}`, timeout, (v) => v?.handoff?.state !== "waiting", (v) => String(v?.handoff?.state ?? null));
409
+ // ── resources ──────────────────────────────────────────────────────────────────────────────────────
410
+ /** Hosted browser sessions: /api/v1/browsers. */
411
+ export class CloudBrowsers {
412
+ http;
413
+ constructor(http) {
414
+ this.http = http;
415
+ }
416
+ /** Start a session; resolves to { id, connectUrl, expiresAt, ... }. For a connected Playwright
417
+ * browser in one step use `launch({ cloud: true, ... })` instead. */
418
+ create(options = {}) {
419
+ return this._create(sessionBody(options));
420
+ }
421
+ /** @internal */
422
+ _create(body) {
423
+ return this.http.call("POST", "/api/v1/browsers", body);
424
+ }
425
+ get(id) {
426
+ return this.http.call("GET", `/api/v1/browsers/${q(id)}`);
427
+ }
428
+ /** { balanceEur, sessions: [...] }, newest first. `status` may be an array. */
429
+ list(opts = {}) {
430
+ const status = Array.isArray(opts.status) ? opts.status.join(",") : opts.status;
431
+ return this.http.call("GET", "/api/v1/browsers", undefined, { status, note: opts.note, limit: opts.limit, before: opts.before });
432
+ }
433
+ stop(id) {
434
+ return this.http.call("DELETE", `/api/v1/browsers/${q(id)}`);
435
+ }
436
+ /** A live-view WebSocket for a running session (`control: true` may also drive it). */
437
+ live(id, opts = {}) {
438
+ return this.http.call("GET", `/api/v1/browsers/${q(id)}/live`, undefined, { control: opts.control ? "1" : undefined });
439
+ }
440
+ /** A link anyone can open: the live view (`control: true` to let them drive) or, with
441
+ * `recording: true`, the session's recording. */
442
+ share(id, opts = {}) {
443
+ return this.http.call("POST", `/api/v1/browsers/${q(id)}/share`, compact({ control: opts.control, minutes: opts.minutes, recording: opts.recording }));
444
+ }
445
+ /** Hand a running session to a person: { state: "waiting", liveUrl, expiresAt, ... }. */
446
+ handoff(id, opts = {}) {
447
+ return this.http.call("POST", `/api/v1/browsers/${q(id)}/handoff`, compact({ reason: opts.reason, timeoutSec: opts.timeoutSec }));
448
+ }
449
+ /** Mark a waiting hand-off done (what the live page's "I'm done" button does). */
450
+ handoffDone(id) {
451
+ return this.http.call("POST", `/api/v1/browsers/${q(id)}/handoff/done`, {});
452
+ }
453
+ /** Poll until the session's hand-off is no longer waiting (done, or timed out on the server);
454
+ * resolves to the session view. `timeout` (seconds) rejects with CloudTimeoutError. A session with no
455
+ * hand-off at all rejects with CloudError NO_HANDOFF rather than resolving at once. */
456
+ async waitHandoff(id, opts = {}) {
457
+ const poll = opts.poll ?? 2;
458
+ const watch = handoffWatch(id, opts.timeout);
459
+ for (;;) {
460
+ let view;
461
+ try {
462
+ view = await this.get(id);
463
+ }
464
+ catch (e) {
465
+ if (!watch.retry(e))
466
+ throw e;
467
+ await sleep(watch.pause(poll));
468
+ continue;
469
+ }
470
+ requireHandoff(id, view, watch.last === null);
471
+ if (watch.seen(view).done)
472
+ return view;
473
+ await sleep(watch.pause(poll));
474
+ }
475
+ }
476
+ /** One page of the session's event timeline: { events: [{ seq, at, type, data }], next }. Pass
477
+ * `next` back as `after` for the following page; it is null at the end. */
478
+ events(id, opts = {}) {
479
+ return this.http.call("GET", `/api/v1/browsers/${q(id)}/events`, undefined, { after: opts.after ?? 0, limit: opts.limit });
480
+ }
481
+ /** A short-lived URL of the session's MP4. Rejects with CloudError 409 NOT_READY while it is still
482
+ * being processed, and 404 when the session was not recorded. */
483
+ async recordingUrl(id) {
484
+ const r = await this.http.raw("GET", `/api/v1/browsers/${q(id)}/recording`);
485
+ let url = null;
486
+ if (r.status >= 300 && r.status < 400 && r.location)
487
+ url = new URL(r.location, this.http.baseUrl + "/").href;
488
+ else {
489
+ const data = parseJson(r.status, r.text);
490
+ if (data && typeof data === "object" && data.url)
491
+ url = String(data.url);
492
+ }
493
+ // only a web URL (as the Python SDK, whose urllib would also open file://)
494
+ if (!url || !/^https?:/i.test(url))
495
+ throw new CloudError(r.status, null, "the API did not answer with a recording URL");
496
+ return url;
497
+ }
498
+ /** Save the session's recording to `path`; resolves to `path`. The storage URL is presigned, so
499
+ * the API key is NOT sent to it. */
500
+ async downloadRecording(id, path) {
501
+ const url = await this.recordingUrl(id);
502
+ const part = `${path}.part`;
503
+ let res;
504
+ try {
505
+ res = await fetch(url, { headers: { "User-Agent": USER_AGENT }, signal: AbortSignal.timeout(Math.max(this.http.timeoutMs, 600_000)) });
506
+ }
507
+ catch (e) {
508
+ throw new CloudError(0, "NETWORK", `downloading the recording failed: ${causeOf(e)}`);
509
+ }
510
+ if (!res.ok || !res.body)
511
+ throw new CloudError(res.status, null, `downloading the recording failed: HTTP ${res.status}`);
512
+ try {
513
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(part));
514
+ renameSync(part, path);
515
+ }
516
+ catch (e) {
517
+ rmSync(part, { force: true });
518
+ throw new CloudError(0, "NETWORK", `downloading the recording failed: ${causeOf(e)}`);
519
+ }
520
+ return path;
521
+ }
522
+ }
523
+ /** Agent runs: a task in, JSON out. /api/v1/runs. */
524
+ export class CloudRuns {
525
+ http;
526
+ constructor(http) {
527
+ this.http = http;
528
+ }
529
+ /**
530
+ * Start a run. With `wait` (the default) poll until it finishes and resolve to the run (status,
531
+ * result with output, costEur, ...); otherwise resolve to the create answer at once. The browser
532
+ * options of a cloud launch apply (country, identity, profile, record, ...) plus maxSteps, handoff
533
+ * and handoffTimeoutSec.
534
+ */
535
+ async create(task, options = {}) {
536
+ const created = await this.http.call("POST", "/api/v1/runs", runBody(task, options));
537
+ if (options.wait === false)
538
+ return created;
539
+ return this.wait(created.id, { timeout: options.timeout, poll: options.poll, onUpdate: options.onUpdate });
540
+ }
541
+ get(id) {
542
+ return this.http.call("GET", `/api/v1/runs/${q(id)}`);
543
+ }
544
+ /** { runs: [...] }, newest first. */
545
+ list(opts = {}) {
546
+ return this.http.call("GET", "/api/v1/runs", undefined, { limit: opts.limit, before: opts.before });
547
+ }
548
+ cancel(id) {
549
+ return this.http.call("DELETE", `/api/v1/runs/${q(id)}`);
550
+ }
551
+ /** Poll GET /api/v1/runs/{id} until the run is succeeded, failed, cancelled or expired. */
552
+ async wait(id, opts = {}) {
553
+ const poll = opts.poll ?? 1.5;
554
+ const watch = runWatch(id, opts.timeout);
555
+ const notify = opts.onUpdate ?? ((v) => { if (v?.status === "waiting_for_human")
556
+ announceHandoff(v); });
557
+ for (;;) {
558
+ let view;
559
+ try {
560
+ view = await this.get(id);
561
+ }
562
+ catch (e) {
563
+ if (!watch.retry(e))
564
+ throw e;
565
+ await sleep(watch.pause(poll));
566
+ continue;
567
+ }
568
+ const { done, changed } = watch.seen(view);
569
+ if (changed)
570
+ await notify(view);
571
+ if (done)
572
+ return view;
573
+ await sleep(watch.pause(poll));
574
+ }
575
+ }
576
+ }
577
+ export const NEED_DOMAINS = "choose the cookies to upload: pass domains: [...] (each also covers its subdomains and the parent-domain cookies a browser sends it), or allDomains: true to upload every cookie";
578
+ /** Cloud profiles (named cookie stores): /api/v1/browsers/profiles. */
579
+ export class CloudProfiles {
580
+ http;
581
+ constructor(http) {
582
+ this.http = http;
583
+ }
584
+ list() {
585
+ return this.http.call("GET", "/api/v1/browsers/profiles");
586
+ }
587
+ /** { name, cookies, domains, bytes, storage, updatedAt }; never the cookie values. */
588
+ get(name) {
589
+ return this.http.call("GET", `/api/v1/browsers/profiles/${q(name)}`);
590
+ }
591
+ delete(name) {
592
+ return this.http.call("DELETE", `/api/v1/browsers/profiles/${q(name)}`);
593
+ }
594
+ /** Upload cookies (CDP or Playwright shape) into a profile, creating it if needed. mode "replace"
595
+ * drops what the profile had first. 409 PROFILE_IN_USE while a live session saves to it. */
596
+ importCookies(name, cookies, opts = {}) {
597
+ return this.http.call("PUT", `/api/v1/browsers/profiles/${q(name)}/cookies`, { cookies: [...cookies], mode: opts.mode ?? "merge" });
598
+ }
599
+ /** Copy a logged-in state into a cloud profile; see {@link SyncOptions}. */
600
+ async sync(name, opts = {}) {
601
+ const sources = [opts.fromProfile, opts.fromCdp, opts.fromFile, opts.loginUrl].filter(Boolean);
602
+ if (sources.length !== 1)
603
+ throw new Error("pass exactly one of fromProfile, fromCdp, fromFile or loginUrl");
604
+ const domains = (typeof opts.domains === "string" ? [opts.domains] : opts.domains ?? []).filter((d) => d && String(d).trim());
605
+ if (domains.length && opts.allDomains)
606
+ throw new Error("pass domains or allDomains, not both");
607
+ if (!domains.length && !opts.allDomains)
608
+ throw new Error(NEED_DOMAINS);
609
+ let cookies;
610
+ if (opts.fromFile)
611
+ cookies = readCookiesFromFile(opts.fromFile);
612
+ else if (opts.fromCdp)
613
+ cookies = await readCookiesFromCdp(opts.fromCdp);
614
+ else if (opts.fromProfile)
615
+ cookies = await readCookiesFromProfile(opts.fromProfile, { serve: opts.serve });
616
+ else
617
+ cookies = await readCookiesByLogin(opts.loginUrl, { confirm: opts.confirm, serve: opts.serve });
618
+ const picked = opts.allDomains ? cookies : filterCookies(cookies, domains);
619
+ if (!picked.length) {
620
+ throw new Error(`no cookies found ${opts.allDomains ? "anywhere" : `for ${domains.join(", ")}`}; nothing was uploaded`);
621
+ }
622
+ return this.importCookies(name, picked.map(normalizeCookie), { mode: opts.replace ? "replace" : "merge" });
623
+ }
624
+ }
625
+ /** Signed event deliveries to your HTTPS endpoint: /api/v1/webhooks. */
626
+ export class CloudWebhooks {
627
+ http;
628
+ constructor(http) {
629
+ this.http = http;
630
+ }
631
+ /** Register an endpoint; the answer carries `secret` (whsec_...), shown only here. */
632
+ create(url, opts = {}) {
633
+ return this.http.call("POST", "/api/v1/webhooks", compact({ url, events: opts.events ? [...opts.events] : undefined, description: opts.description }));
634
+ }
635
+ list() {
636
+ return this.http.call("GET", "/api/v1/webhooks");
637
+ }
638
+ delete(id) {
639
+ return this.http.call("DELETE", `/api/v1/webhooks/${q(id)}`);
640
+ }
641
+ /** Send a `ping` event to the endpoint. */
642
+ test(id) {
643
+ return this.http.call("POST", `/api/v1/webhooks/${q(id)}/test`, {});
644
+ }
645
+ }
646
+ /** The hosted API, one client: `browsers`, `runs`, `profiles` and `webhooks`. */
647
+ export class Cloud {
648
+ baseUrl;
649
+ browsers;
650
+ runs;
651
+ profiles;
652
+ webhooks;
653
+ constructor(opts = {}) {
654
+ const key = String(opts.apiKey || process.env.CLEARCOTE_API_KEY || "").trim();
655
+ if (!key)
656
+ throw new Error(NO_API_KEY);
657
+ const base = String(opts.baseUrl || process.env.CLEARCOTE_API_URL || DEFAULT_API_URL).trim().replace(/\/+$/, "");
658
+ this.baseUrl = checkBaseUrl(base);
659
+ const http = new Http(key, base, (opts.timeout ?? 30) * 1000);
660
+ this.browsers = new CloudBrowsers(http);
661
+ this.runs = new CloudRuns(http);
662
+ this.profiles = new CloudProfiles(http);
663
+ this.webhooks = new CloudWebhooks(http);
664
+ }
665
+ /** Never the key. */
666
+ toJSON() {
667
+ return { baseUrl: this.baseUrl };
668
+ }
669
+ }
670
+ // ── webhooks: signature check ──────────────────────────────────────────────────────────────────────
671
+ /**
672
+ * Check a webhook delivery and return its parsed JSON event.
673
+ *
674
+ * `rawBody` must be the request body EXACTLY as received (a string or Buffer): re-serialised JSON does
675
+ * not match the signature. `signatureHeader` is the Clearcote-Signature header,
676
+ * `t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`; more than one v1 may be present
677
+ * (while a secret rotates) and any one matching is enough. Comparisons are constant-time. A timestamp
678
+ * more than `toleranceSec` away from now is refused, so a captured delivery cannot be replayed later
679
+ * (`toleranceSec: null` turns that off). Throws on any failure.
680
+ */
681
+ // A real header is ~80 bytes (one t, one or two v1). Anything near this is not a Clearcote signature.
682
+ const MAX_SIGNATURE_HEADER = 4096;
683
+ export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300, nowSec) {
684
+ if (secret === undefined || secret === null || !String(secret).trim()) {
685
+ // String(undefined) is "undefined": an unset secret (process.env.X) would otherwise verify anything
686
+ // signed with the key "undefined", which anyone can compute.
687
+ throw new Error("verifyWebhook needs the signing secret of the endpoint (whsec_...), got none");
688
+ }
689
+ const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : Buffer.from(rawBody);
690
+ const header = String(signatureHeader ?? "");
691
+ if (header.length > MAX_SIGNATURE_HEADER) {
692
+ throw new Error("invalid Clearcote-Signature header (expected t=<unix seconds>,v1=<hex>)");
693
+ }
694
+ let stamp = null;
695
+ const signatures = [];
696
+ for (const part of header.split(",")) {
697
+ const i = part.indexOf("=");
698
+ if (i < 0)
699
+ continue;
700
+ const key = part.slice(0, i).trim();
701
+ const value = part.slice(i + 1).trim();
702
+ if (key === "t")
703
+ stamp = value;
704
+ else if (key === "v1" && value)
705
+ signatures.push(value.toLowerCase());
706
+ }
707
+ if (!stamp || !/^[0-9]+$/.test(stamp) || !signatures.length) {
708
+ throw new Error("invalid Clearcote-Signature header (expected t=<unix seconds>,v1=<hex>)");
709
+ }
710
+ const expected = Buffer.from(createHmac("sha256", String(secret)).update(Buffer.concat([Buffer.from(`${stamp}.`, "ascii"), body])).digest("hex"), "ascii");
711
+ const ok = signatures.some((s) => {
712
+ const got = Buffer.from(s, "utf8");
713
+ return got.length === expected.length && timingSafeEqual(got, expected);
714
+ });
715
+ if (!ok)
716
+ throw new Error("webhook signature does not match");
717
+ if (toleranceSec !== null && toleranceSec !== undefined) {
718
+ const now = nowSec ?? Date.now() / 1000;
719
+ if (Math.abs(now - Number(stamp)) > toleranceSec) {
720
+ throw new Error("webhook timestamp is outside the tolerance window (a replay, or a clock that is off)");
721
+ }
722
+ }
723
+ return JSON.parse(body.toString("utf8"));
724
+ }
725
+ // ── cookies: profile sync ──────────────────────────────────────────────────────────────────────────
726
+ // What PUT .../cookies keeps of a cookie (CookieParam). The CDP shape carries more (size, priority,
727
+ // sourceScheme, ...); the API drops those anyway, so they are not uploaded at all.
728
+ const COOKIE_FIELDS = ["name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite"];
729
+ export function normalizeCookie(cookie) {
730
+ return Object.fromEntries(COOKIE_FIELDS.filter((k) => cookie[k] !== undefined && cookie[k] !== null).map((k) => [k, cookie[k]]));
731
+ }
732
+ const bareDomain = (d) => String(d ?? "").trim().toLowerCase().replace(/^\.+/, "");
733
+ /** `cookieDomain` is `allowed`, a subdomain of it, or a PARENT domain of it (a .example.com cookie is
734
+ * sent to www.example.com too, so signing in there needs it). A parent must still have a dot of its own:
735
+ * a cookie on a bare suffix (com) never matches. */
736
+ function domainMatches(cookieDomain, allowed) {
737
+ if (cookieDomain === allowed || cookieDomain.endsWith(`.${allowed}`))
738
+ return true;
739
+ return cookieDomain.includes(".") && allowed.endsWith(`.${cookieDomain}`);
740
+ }
741
+ /** The cookies a browser would use on `domains`: those whose domain is one of them, a subdomain of one,
742
+ * or a parent domain of one (.example.com for www.example.com; never a bare suffix such as com). A
743
+ * leading dot on either side is ignored, and example.com does not match badexample.com. */
744
+ export function filterCookies(cookies, domains) {
745
+ const allowed = domains.map(bareDomain).filter(Boolean);
746
+ return cookies.filter((c) => {
747
+ const d = bareDomain(c?.domain);
748
+ return !!d && allowed.some((a) => domainMatches(d, a));
749
+ });
750
+ }
751
+ /** The cookies in a Playwright storage state ({ cookies, origins }), a CDP { cookies } answer, or a
752
+ * plain JSON array of cookies. */
753
+ export function cookiesFromState(data) {
754
+ const cookies = data && !Array.isArray(data) && typeof data === "object" ? data.cookies : data;
755
+ if (!Array.isArray(cookies)) {
756
+ throw new Error('expected a Playwright storage state ({"cookies": [...]}) or a JSON array of cookies');
757
+ }
758
+ for (const c of cookies) {
759
+ if (!c || typeof c !== "object" || !c.name || !c.domain)
760
+ throw new Error("every cookie needs at least a name and a domain");
761
+ }
762
+ return cookies;
763
+ }
764
+ export function readCookiesFromFile(path) {
765
+ let data;
766
+ try {
767
+ data = JSON.parse(readFileSync(path, "utf8"));
768
+ }
769
+ catch (e) {
770
+ if (e.code)
771
+ throw e;
772
+ throw new Error(`${path} is not JSON: ${e.message}`);
773
+ }
774
+ return cookiesFromState(data);
775
+ }
776
+ /** Every cookie of a running browser's default context, over CDP (Storage.getCookies on the browser
777
+ * target). Closing a CDP connection only disconnects: the browser keeps running. */
778
+ export async function readCookiesFromCdp(endpoint) {
779
+ if (!/^(wss?|https?):\/\//.test(String(endpoint).trim())) {
780
+ throw new Error("fromCdp must be an http(s):// or ws(s):// CDP endpoint, e.g. http://127.0.0.1:9222");
781
+ }
782
+ const browser = await chromium.connectOverCDP(String(endpoint).trim());
783
+ try {
784
+ const session = await browser.newBrowserCDPSession();
785
+ const { cookies } = (await session.send("Storage.getCookies"));
786
+ return [...(cookies ?? [])];
787
+ }
788
+ finally {
789
+ await browser.close();
790
+ }
791
+ }
792
+ const defaultServe = async (opts) => (await import("./index.js")).serve(opts);
793
+ /** Start a headless Clearcote on `userDataDir` (a raw CDP endpoint via serve(): no Playwright launch,
794
+ * nothing written but what Chrome itself writes), read its cookies, stop it. */
795
+ export async function readCookiesFromProfile(userDataDir, opts = {}) {
796
+ if (!existsSync(userDataDir) || !statSync(userDataDir).isDirectory())
797
+ throw new Error(`${userDataDir} is not a directory`);
798
+ const srv = await (opts.serve ?? defaultServe)({ userDataDir, headless: true, quiet: true });
799
+ try {
800
+ return await readCookiesFromCdp(srv.cdpUrl);
801
+ }
802
+ finally {
803
+ await srv.close();
804
+ }
805
+ }
806
+ export const LOGIN_PROMPT = "Sign in in the browser window that just opened, then press Enter here to upload the cookies.";
807
+ async function waitForEnter() {
808
+ if (!process.stdin.isTTY) {
809
+ throw new Error("login needs an interactive terminal to confirm the sign-in (pass confirm to wait another way)");
810
+ }
811
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
812
+ await new Promise((resolve) => rl.question(`${LOGIN_PROMPT} `, () => resolve()));
813
+ rl.close();
814
+ }
815
+ /** Open `url` in a VISIBLE Clearcote on a throwaway profile, wait for `confirm()` (default: Enter in
816
+ * this terminal), then read the cookies. The profile directory is deleted afterwards. */
817
+ export async function readCookiesByLogin(url, opts = {}) {
818
+ const srv = await (opts.serve ?? defaultServe)({ headless: false, quiet: true });
819
+ try {
820
+ const browser = await chromium.connectOverCDP(srv.cdpUrl);
821
+ try {
822
+ await (await browser.newBrowserCDPSession()).send("Target.createTarget", { url });
823
+ }
824
+ finally {
825
+ await browser.close();
826
+ }
827
+ await (opts.confirm ?? waitForEnter)();
828
+ if (!srv.isAlive())
829
+ throw new Error("the browser was closed before the cookies were read; nothing was uploaded");
830
+ return await readCookiesFromCdp(srv.cdpUrl);
831
+ }
832
+ finally {
833
+ await srv.close();
834
+ }
835
+ }
836
+ // ── launch({ cloud: true }) ────────────────────────────────────────────────────────────────────────
837
+ /** The hosted session a cloud launch() is connected to: the create answer minus the single-use
838
+ * connect URL (it holds a token and is spent once connected). Also on `browser.cloudSession`. */
839
+ export function cloudSessionOf(target) {
840
+ return target.cloudSession;
841
+ }
842
+ function prepareLaunch(options, persistent, userDataDir) {
843
+ const opts = { ...options };
844
+ delete opts.cloud;
845
+ const sdk = {};
846
+ for (const k of SDK_SIDE_OPTIONS) {
847
+ if (k in opts) {
848
+ sdk[k] = opts[k];
849
+ delete opts[k];
850
+ }
851
+ }
852
+ if (persistent) {
853
+ if (userDataDir !== undefined && userDataDir !== null)
854
+ throw new Error(USER_DATA_DIR_MSG);
855
+ const profile = opts.profile;
856
+ if (!profile) {
857
+ throw new Error('launchPersistentContext({ cloud: true }) needs profile: "name": the cloud profile whose cookies it loads and saves back when it closes');
858
+ }
859
+ opts.profile = typeof profile === "string" ? { name: profile, persist: true }
860
+ : profile && typeof profile === "object" && Object.getPrototypeOf(profile) === Object.prototype ? { persist: true, ...profile }
861
+ : profile;
862
+ }
863
+ return { sdk, body: sessionBody(opts) };
864
+ }
865
+ function connectUrlOf(created) {
866
+ if (!created?.connectUrl)
867
+ throw new CloudError(200, null, `the API created session ${created?.id} but sent no connectUrl`);
868
+ return created.connectUrl;
869
+ }
870
+ async function stopQuietly(client, created) {
871
+ try {
872
+ if (created?.id)
873
+ await client.browsers.stop(created.id);
874
+ }
875
+ catch { /* best-effort: the gateway ends it when the client goes anyway */ }
876
+ }
877
+ /** A new context would otherwise get Playwright's emulated 1280x720 viewport on top of the real
878
+ * window: the impossible-window tell a local launch avoids the same way. */
879
+ function defaultNoViewport(browser) {
880
+ const origNewPage = browser.newPage.bind(browser);
881
+ browser.newPage = (o = {}) => origNewPage(("viewport" in o ? o : { ...o, viewport: null }));
882
+ const origNewContext = browser.newContext.bind(browser);
883
+ browser.newContext = (o = {}) => origNewContext(("viewport" in o ? o : { ...o, viewport: null }));
884
+ }
885
+ /**
886
+ * `launch({ cloud })`: create the session, connect over CDP, humanize as a local launch does, and
887
+ * resolve to the Playwright Browser (or, `persistent`, its profile context). Exported for index.ts.
888
+ * Nothing is left behind when a step after the create fails: the connection is closed and the session
889
+ * stopped (DELETE), so a failed launch never keeps a billed browser running.
890
+ */
891
+ export async function launchCloud(options, persistent = false, userDataDir) {
892
+ const cloud = options.cloud;
893
+ const { sdk, body } = prepareLaunch(options, persistent, userDataDir);
894
+ const client = cloud instanceof Cloud ? cloud : new Cloud({ apiKey: sdk.apiKey, baseUrl: sdk.apiUrl });
895
+ const created = await client.browsers._create(body);
896
+ const connect = {};
897
+ for (const k of CONNECT_OPTIONS)
898
+ if (sdk[k] != null)
899
+ connect[k] = sdk[k];
900
+ let disconnect = null;
901
+ try {
902
+ const browser = await chromium.connectOverCDP(connectUrlOf(created), connect);
903
+ const origClose = browser.close.bind(browser);
904
+ disconnect = () => origClose();
905
+ const info = Object.fromEntries(Object.entries(created).filter(([k]) => k !== "connectUrl"));
906
+ Object.defineProperty(browser, "cloudSession", { value: info, enumerable: false, configurable: true });
907
+ const keepAlive = body.keepAlive === true;
908
+ browser.close = async (...args) => {
909
+ // Disconnect first (the gateway ends a session whose client goes), then say so explicitly.
910
+ // A keep-alive session is left running on purpose: stop it with cloud.browsers.stop(id).
911
+ try {
912
+ return await origClose(...args);
913
+ }
914
+ finally {
915
+ if (!keepAlive)
916
+ await stopQuietly(client, created);
917
+ }
918
+ };
919
+ defaultNoViewport(browser);
920
+ // The humanizer's persona seed: the fingerprint, else the identity label, so one identity keeps one
921
+ // hand across sessions (a local launch seeds from the fingerprint the same way).
922
+ const seed = (body.fingerprint ?? body.identity);
923
+ const humanize = { humanize: sdk.humanize, showCursor: sdk.showCursor, seed };
924
+ for (const ctx of browser.contexts())
925
+ installHumanizeOnContext(ctx, humanize, browser); // the session's own context and its tabs
926
+ installHumanize(browser, humanize);
927
+ if (!persistent)
928
+ return browser;
929
+ const context = browser.contexts()[0] ?? (await browser.newContext());
930
+ Object.defineProperty(context, "cloudSession", { value: info, enumerable: false, configurable: true });
931
+ // Closing the profile context closes (and saves) the session.
932
+ context.close = async () => { await browser.close(); };
933
+ return context;
934
+ }
935
+ catch (e) {
936
+ if (disconnect)
937
+ await disconnect().catch(() => { });
938
+ await stopQuietly(client, created);
939
+ throw e;
940
+ }
941
+ }
942
+ //# sourceMappingURL=cloud.js.map