@scalebrowser/sdk 0.2.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/index.js ADDED
@@ -0,0 +1,1239 @@
1
+ import WebSocket from 'ws';
2
+
3
+ // src/errors.ts
4
+ var ErrorCode = {
5
+ NotFound: 4001,
6
+ ProxyGeo: 4002,
7
+ Capacity: 4003,
8
+ EngineMissing: 4004,
9
+ Preflight: 4005,
10
+ AlreadyRunning: 4006,
11
+ /** Retired: the removed Methode-D (VM) tier produced this. Reserved (never reused)
12
+ * to match the server's append-only code contract; never returned now. */
13
+ VmProvisionFailed: 4007,
14
+ /** The subscription's abo-wide concurrent-browser allowance is used up (DEC-012).
15
+ * Distinct from Capacity (4003 = this machine is full, wait): the caller's move
16
+ * is to stop a running browser anywhere or upgrade the plan. */
17
+ ConcurrencyLimit: 4008,
18
+ Unauthorized: 4010
19
+ };
20
+ var CODE_LABELS = {
21
+ 4001: "Not found",
22
+ 4002: "Proxy or geo check failed",
23
+ 4003: "Capacity exceeded",
24
+ 4004: "Engine missing",
25
+ 4005: "Launch preflight failed",
26
+ 4006: "Profile already running",
27
+ 4008: "Concurrent-browser limit reached (subscription-wide)",
28
+ 4010: "Unauthorized"
29
+ };
30
+ var ScalebrowserError = class extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = new.target.name;
34
+ }
35
+ };
36
+ var ApiError = class extends ScalebrowserError {
37
+ status;
38
+ code;
39
+ body;
40
+ constructor(status, body, fallbackMessage) {
41
+ const label = body?.code ? CODE_LABELS[body.code] : void 0;
42
+ super(body?.message || label || fallbackMessage || `Request failed (HTTP ${status})`);
43
+ this.status = status;
44
+ this.code = body?.code;
45
+ this.body = body;
46
+ }
47
+ /** True for auth failures that must trigger re-authentication. */
48
+ get isAuthError() {
49
+ return this.status === 401 || this.code === ErrorCode.Unauthorized;
50
+ }
51
+ /** Short label for the error code, if any. */
52
+ get codeLabel() {
53
+ return this.code ? CODE_LABELS[this.code] : void 0;
54
+ }
55
+ };
56
+ var NetworkError = class extends ScalebrowserError {
57
+ cause;
58
+ constructor(message, cause) {
59
+ super(message);
60
+ this.cause = cause;
61
+ }
62
+ };
63
+ var CdpError = class extends ScalebrowserError {
64
+ code;
65
+ data;
66
+ constructor(code, message, data) {
67
+ super(message);
68
+ this.code = code;
69
+ this.data = data;
70
+ }
71
+ };
72
+ function errorMessage(err) {
73
+ if (err instanceof Error) return err.message;
74
+ return String(err);
75
+ }
76
+
77
+ // src/client-core.ts
78
+ var DEFAULT_BASE_URL = "http://127.0.0.1:8787";
79
+ var ClientCore = class {
80
+ baseUrl;
81
+ #token;
82
+ #fetch;
83
+ constructor(options = {}) {
84
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
85
+ this.#token = options.token ?? null;
86
+ this.#fetch = options.fetch ?? globalThis.fetch;
87
+ }
88
+ /** For subclasses that hand the token to a sibling transport (SSE, CDP). */
89
+ get token() {
90
+ return this.#token;
91
+ }
92
+ get fetchImpl() {
93
+ return this.#fetch;
94
+ }
95
+ /** Rotate the Bearer token at runtime. */
96
+ setToken(token) {
97
+ this.#token = token && token.trim() ? token.trim() : null;
98
+ }
99
+ // ── Internals ────────────────────────────────────────────────────────────
100
+ async request(method, path, body, opts) {
101
+ const headers = {};
102
+ if (this.#token) headers["Authorization"] = `Bearer ${this.#token}`;
103
+ let payload;
104
+ if (body instanceof Uint8Array || body instanceof ArrayBuffer) {
105
+ headers["Content-Type"] = "application/octet-stream";
106
+ payload = body instanceof ArrayBuffer ? new Uint8Array(body) : body;
107
+ } else if (body !== void 0) {
108
+ headers["Content-Type"] = "application/json";
109
+ payload = JSON.stringify(body);
110
+ }
111
+ let res;
112
+ try {
113
+ res = await this.#fetch(this.#buildUrl(path, opts?.query), {
114
+ method,
115
+ headers,
116
+ body: payload,
117
+ signal: opts?.signal
118
+ });
119
+ } catch (cause) {
120
+ throw new NetworkError("Could not reach the daemon. Is it running?", cause);
121
+ }
122
+ if (!res.ok) {
123
+ throw new ApiError(res.status, await safeJson(res), res.status === 401 ? "Unauthorized" : void 0);
124
+ }
125
+ if (res.status === 204) return void 0;
126
+ const text = await res.text();
127
+ return text ? JSON.parse(text) : void 0;
128
+ }
129
+ /**
130
+ * A route that answers with bytes, not JSON — artifacts and run stills.
131
+ *
132
+ * Separate from {@link ClientCore.request} rather than a flag on it, because the two
133
+ * differ in the error path as well: a failed byte fetch still answers JSON,
134
+ * so the mapping to {@link ApiError} has to happen before the body is read as
135
+ * binary.
136
+ */
137
+ async requestBytes(method, path, opts) {
138
+ const headers = {};
139
+ if (this.#token) headers["Authorization"] = `Bearer ${this.#token}`;
140
+ let res;
141
+ try {
142
+ res = await this.#fetch(this.#buildUrl(path, opts?.query), {
143
+ method,
144
+ headers,
145
+ signal: opts?.signal
146
+ });
147
+ } catch (cause) {
148
+ throw new NetworkError("Could not reach the daemon. Is it running?", cause);
149
+ }
150
+ if (!res.ok) {
151
+ throw new ApiError(res.status, await safeJson(res), res.status === 401 ? "Unauthorized" : void 0);
152
+ }
153
+ const disposition = res.headers.get("content-disposition");
154
+ return {
155
+ bytes: new Uint8Array(await res.arrayBuffer()),
156
+ content_type: res.headers.get("content-type"),
157
+ filename: parseFilename(disposition)
158
+ };
159
+ }
160
+ #buildUrl(path, query) {
161
+ const url = this.baseUrl + path;
162
+ if (!query) return url;
163
+ const params = new URLSearchParams();
164
+ for (const [key, value] of Object.entries(query)) {
165
+ if (value !== void 0 && value !== null && value !== "") params.append(key, String(value));
166
+ }
167
+ const qs = params.toString();
168
+ return qs ? `${url}?${qs}` : url;
169
+ }
170
+ /** Never leak the token in logs / inspection. */
171
+ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
172
+ return `${this.constructor.name} { baseUrl: '${this.baseUrl}', token: ${this.#token ? "'***'" : "null"} }`;
173
+ }
174
+ };
175
+ function applyMixins(target, mixins) {
176
+ for (const mixin of mixins) {
177
+ for (const name of Object.getOwnPropertyNames(mixin.prototype)) {
178
+ if (name === "constructor") continue;
179
+ Object.defineProperty(
180
+ target.prototype,
181
+ name,
182
+ Object.getOwnPropertyDescriptor(mixin.prototype, name)
183
+ );
184
+ }
185
+ }
186
+ }
187
+ function parseFilename(disposition) {
188
+ if (!disposition) return null;
189
+ const match = /filename="([^"]*)"/.exec(disposition) ?? /filename=([^;]+)/.exec(disposition);
190
+ const name = match?.[1]?.trim();
191
+ return name ? name : null;
192
+ }
193
+ async function safeJson(res) {
194
+ try {
195
+ const text = await res.text();
196
+ return text ? JSON.parse(text) : void 0;
197
+ } catch {
198
+ return void 0;
199
+ }
200
+ }
201
+ function enc(id) {
202
+ return encodeURIComponent(id);
203
+ }
204
+
205
+ // src/client-control.ts
206
+ var SELF_HOSTED = {
207
+ licensed: false,
208
+ running_local: 0,
209
+ profiles_local: 0,
210
+ permits_launch: true
211
+ };
212
+ var ControlApi = class extends ClientCore {
213
+ // ── Interruptions ──────────────────────────────────────────────────────────
214
+ /** Which browser surfaces are locked shut, and what actually applies. */
215
+ listInterruptionLocks(signal) {
216
+ return this.request("GET", "/v1/interruptions/locks", void 0, { signal });
217
+ }
218
+ /** Omit `profile_id` to set the fleet-wide default. */
219
+ setInterruptionLock(body) {
220
+ return this.request("PUT", "/v1/interruptions/locks", body);
221
+ }
222
+ /** Standing answers per origin and kind. */
223
+ listInterruptionRules(signal) {
224
+ return this.request("GET", "/v1/interruptions/rules", void 0, { signal });
225
+ }
226
+ setInterruptionRule(body) {
227
+ return this.request("PUT", "/v1/interruptions/rules", body);
228
+ }
229
+ deleteInterruptionRule(params) {
230
+ return this.request("DELETE", "/v1/interruptions/rules", void 0, {
231
+ query: { origin: params.origin, kind: params.kind, profile_id: params.profile_id }
232
+ });
233
+ }
234
+ // ── Artifacts ──────────────────────────────────────────────────────────────
235
+ /**
236
+ * Hand the daemon a file so `interact action=upload` has something to attach.
237
+ *
238
+ * The bytes are bound to the PROFILE, not to a reservation: set a file up
239
+ * before leasing, and a lease released mid-task does not destroy it.
240
+ */
241
+ putArtifact(profileId, bytes, name) {
242
+ return this.request("POST", `/v1/profiles/${enc(profileId)}/artifacts`, bytes, {
243
+ query: { name }
244
+ });
245
+ }
246
+ /**
247
+ * Fetch one artifact's bytes — a screenshot, a download, a saved PDF.
248
+ *
249
+ * A miss is a plain 404 whether the id never existed, expired, or belonged to
250
+ * a released lease: the caller's next move is the same in all three cases.
251
+ */
252
+ getArtifact(artifactId, signal) {
253
+ return this.requestBytes("GET", `/v1/artifacts/${enc(artifactId)}`, { signal });
254
+ }
255
+ // ── Account + health ───────────────────────────────────────────────────────
256
+ /**
257
+ * Licence state for display. The launch gate stays the authority on starts.
258
+ *
259
+ * A daemon predating the route answers 404, and that is not an error worth
260
+ * throwing: it means "self-hosted, no control plane", which is exactly what
261
+ * `licensed: false` says. Same shape of tolerance as {@link getMetrics}.
262
+ */
263
+ async getAccount(signal) {
264
+ try {
265
+ return await this.request("GET", "/v1/account", void 0, { signal });
266
+ } catch (err) {
267
+ if (err instanceof ApiError && err.status === 404) return { ...SELF_HOSTED };
268
+ throw err;
269
+ }
270
+ }
271
+ /** "Is the daemon alive?" Public, needs no token. */
272
+ health(signal) {
273
+ return this.request("GET", "/health", void 0, { signal });
274
+ }
275
+ /** "Can it serve?" Local facts only — the control plane is deliberately not consulted. */
276
+ ready(signal) {
277
+ return this.request("GET", "/health/ready", void 0, { signal });
278
+ }
279
+ };
280
+
281
+ // src/client-extensions.ts
282
+ var ExtensionsApi = class extends ClientCore {
283
+ // ── Extension library (daemon-wide) ─────────────────────────────────────────
284
+ //
285
+ // Upload a `.crx`; the daemon lifts its public key out of the CRX header and
286
+ // writes it into the unpacked `manifest.json`, so every profile loads the
287
+ // package under its CANONICAL Web-Store id. A key-less package is refused — it
288
+ // would take a path-derived id, identical across the fleet.
289
+ /** The library, newest first. */
290
+ listLibraryExtensions(signal) {
291
+ return this.request("GET", "/v1/extensions", void 0, { signal });
292
+ }
293
+ /**
294
+ * Add a package. `crx` is the raw `.crx` file. A new VERSION is stocked alongside the
295
+ * existing ones (profiles draw their own); re-uploading the same version replaces it.
296
+ */
297
+ uploadExtension(crx) {
298
+ return this.request("POST", "/v1/extensions", crx);
299
+ }
300
+ /** One library entry — the most recently added version. */
301
+ getLibraryExtension(id, signal) {
302
+ return this.request("GET", `/v1/extensions/${enc(id)}`, void 0, { signal });
303
+ }
304
+ /** Remove EVERY version of a package, its files and every profile's assignment of it. */
305
+ deleteLibraryExtension(id) {
306
+ return this.request("DELETE", `/v1/extensions/${enc(id)}`);
307
+ }
308
+ /**
309
+ * Remove ONE stocked version.
310
+ *
311
+ * Assignments survive — they name the extension, not the version — unless this
312
+ * was the last version, in which case they go with it.
313
+ */
314
+ deleteLibraryExtensionVersion(id, version) {
315
+ return this.request("DELETE", `/v1/extensions/${enc(id)}/${enc(version)}`);
316
+ }
317
+ // ── Extensions per profile ──────────────────────────────────────────────────
318
+ //
319
+ // `extRef` is a LIBRARY ID, not a path. The launch copies each assigned package
320
+ // into the profile's own tree and loads that, so files and state stay per-profile
321
+ // while the id stays shared (which is the camouflage, not the leak).
322
+ /** The profile's assigned set, resolved, + the launch policy. */
323
+ listExtensions(profileId, signal) {
324
+ return this.request(
325
+ "GET",
326
+ `/v1/profiles/${enc(profileId)}/extensions`,
327
+ void 0,
328
+ { signal }
329
+ );
330
+ }
331
+ /** Assign a library id (idempotent) → the updated set + policy. 404 if unknown. */
332
+ attachExtension(profileId, extRef) {
333
+ return this.request("POST", `/v1/profiles/${enc(profileId)}/extensions`, {
334
+ ext_ref: extRef
335
+ });
336
+ }
337
+ /** Unassign a library id (idempotent) → the updated set + policy. */
338
+ detachExtension(profileId, extRef) {
339
+ return this.request("DELETE", `/v1/profiles/${enc(profileId)}/extensions`, {
340
+ ext_ref: extRef
341
+ });
342
+ }
343
+ };
344
+
345
+ // src/client-identity.ts
346
+ var IdentityApi = class extends ClientCore {
347
+ // ── Platform credentials ────────────────────────────────────────────────────
348
+ //
349
+ // Stored logins, so an agent can sign a profile into a platform WITHOUT ever
350
+ // holding the password — the daemon decrypts and types it. Only `revealCredential`
351
+ // and the bundle calls return plaintext, and every one of them needs the vault
352
+ // password: the bearer token alone is not enough, because the MCP agent layer
353
+ // authenticates with exactly the same one.
354
+ //
355
+ // `platform` is whatever you have: the service's domain ("discord.com"), its
356
+ // name ("Discord") or a host under it ("www.discord.com"). The daemon resolves
357
+ // all of them to ONE key — the registrable domain — and every response echoes
358
+ // that key, so `listCredentials` and a later `deleteCredential` agree. Pass a
359
+ // whole sign-in URL only to the MCP tools: here the platform is a path segment,
360
+ // and a `/` in it is refused rather than read as a path.
361
+ /** Which platforms this profile can log into. Never carries a secret. */
362
+ listCredentials(profileId, signal) {
363
+ return this.request(
364
+ "GET",
365
+ `/v1/profiles/${enc(profileId)}/credentials`,
366
+ void 0,
367
+ { signal }
368
+ );
369
+ }
370
+ /**
371
+ * Store or update one login.
372
+ *
373
+ * An omitted secret keeps its stored value rather than clearing it — an edit form
374
+ * never saw the password in the clear, so it cannot resend one; delete the row to
375
+ * remove a credential. `totp_secret` may be pasted exactly as a site presents it
376
+ * (grouped, lower case): it is normalised and test-decoded by the daemon, so a
377
+ * broken key is refused here rather than half-way through a login.
378
+ */
379
+ putCredential(profileId, platform, body) {
380
+ return this.request(
381
+ "PUT",
382
+ `/v1/profiles/${enc(profileId)}/credentials/${enc(platform)}`,
383
+ body
384
+ );
385
+ }
386
+ /** Forget one login. The account itself is untouched. */
387
+ deleteCredential(profileId, platform) {
388
+ return this.request(
389
+ "DELETE",
390
+ `/v1/profiles/${enc(profileId)}/credentials/${enc(platform)}`
391
+ );
392
+ }
393
+ /** Read one login back in the clear. Needs the vault password. */
394
+ revealCredential(profileId, platform, vaultPassword) {
395
+ return this.request(
396
+ "POST",
397
+ `/v1/profiles/${enc(profileId)}/credentials/${enc(platform)}/reveal`,
398
+ { vault_password: vaultPassword }
399
+ );
400
+ }
401
+ /**
402
+ * Pack this profile's logins into a bundle sealed with `password`.
403
+ *
404
+ * `password` is deliberately separate from the vault password: a backup gets
405
+ * handed to another machine, and that must not also hand over daemon access. This
406
+ * is the answer to a lost master key — a daemon-generated password exists nowhere
407
+ * else.
408
+ */
409
+ exportCredentials(profileId, vaultPassword, password) {
410
+ return this.request(
411
+ "POST",
412
+ `/v1/profiles/${enc(profileId)}/credential-bundle/export`,
413
+ { vault_password: vaultPassword, password }
414
+ );
415
+ }
416
+ /** Restore a bundle into this profile. */
417
+ importCredentials(profileId, vaultPassword, password, bundle) {
418
+ return this.request(
419
+ "POST",
420
+ `/v1/profiles/${enc(profileId)}/credential-bundle/import`,
421
+ { vault_password: vaultPassword, password, bundle }
422
+ );
423
+ }
424
+ /** Has a vault password been set on this daemon? */
425
+ vaultStatus(signal) {
426
+ return this.request("GET", "/v1/vault", void 0, { signal });
427
+ }
428
+ /** Set the vault password, or change it (then `currentPassword` is required). */
429
+ setVaultPassword(newPassword, currentPassword) {
430
+ return this.request("PUT", "/v1/vault", {
431
+ new_password: newPassword,
432
+ current_password: currentPassword
433
+ });
434
+ }
435
+ // ── Mailboxes ──────────────────────────────────────────────────────────────
436
+ /** Mailboxes, passwords never returned. */
437
+ listInboxes(signal) {
438
+ return this.request("GET", "/v1/inboxes", void 0, { signal });
439
+ }
440
+ createInbox(body) {
441
+ return this.request("POST", "/v1/inboxes", body);
442
+ }
443
+ /** An omitted `password` leaves the stored one unchanged. */
444
+ updateInbox(inboxId, body) {
445
+ return this.request("PUT", `/v1/inboxes/${enc(inboxId)}`, body);
446
+ }
447
+ deleteInbox(inboxId) {
448
+ return this.request("DELETE", `/v1/inboxes/${enc(inboxId)}`);
449
+ }
450
+ /** Which mailbox a profile's confirmation codes arrive in, per channel. */
451
+ getInboxBindings(profileId, signal) {
452
+ return this.request("GET", `/v1/profiles/${enc(profileId)}/inbox`, void 0, {
453
+ signal
454
+ });
455
+ }
456
+ bindInbox(profileId, body) {
457
+ return this.request("PUT", `/v1/profiles/${enc(profileId)}/inbox`, body);
458
+ }
459
+ unbindInbox(profileId, channel) {
460
+ return this.request("DELETE", `/v1/profiles/${enc(profileId)}/inbox/${enc(channel)}`);
461
+ }
462
+ // ── Passkeys ───────────────────────────────────────────────────────────────
463
+ /** What this profile can sign in to without a password. Metadata only. */
464
+ listPasskeys(profileId, signal) {
465
+ return this.request("GET", `/v1/profiles/${enc(profileId)}/passkeys`, void 0, {
466
+ signal
467
+ });
468
+ }
469
+ /**
470
+ * Retire a passkey.
471
+ *
472
+ * Calling it twice is meaningful: the first call retires a live key and leaves
473
+ * a headstone, the second clears the headstone for good.
474
+ */
475
+ deletePasskey(profileId, credentialId) {
476
+ return this.request("DELETE", `/v1/profiles/${enc(profileId)}/passkeys/${enc(credentialId)}`);
477
+ }
478
+ // ── Cookies ────────────────────────────────────────────────────────────────
479
+ /**
480
+ * The one door a cookie VALUE leaves through, and it needs the vault password.
481
+ *
482
+ * A session cookie skips both the password and the second factor, which is why
483
+ * every other cookie surface answers with metadata only.
484
+ */
485
+ revealCookies(profileId, body) {
486
+ return this.request("POST", `/v1/profiles/${enc(profileId)}/cookies/reveal`, body);
487
+ }
488
+ };
489
+
490
+ // src/client-runs.ts
491
+ var RunsApi = class extends ClientCore {
492
+ // ── Agent runs ─────────────────────────────────────────────────────────────
493
+ /** Runs, newest first. Read-only, all of it. */
494
+ listRuns(params = {}, signal) {
495
+ return this.request("GET", "/v1/runs", void 0, {
496
+ query: { profile_id: params.profile_id, limit: params.limit, offset: params.offset },
497
+ signal
498
+ });
499
+ }
500
+ /** One run. */
501
+ getRun(runId, signal) {
502
+ return this.request("GET", `/v1/runs/${enc(runId)}`, void 0, { signal });
503
+ }
504
+ /** A run's steps, oldest first — the order they happened in. */
505
+ listRunSteps(runId, params = {}, signal) {
506
+ return this.request("GET", `/v1/runs/${enc(runId)}/steps`, void 0, {
507
+ query: { limit: params.limit, offset: params.offset },
508
+ signal
509
+ });
510
+ }
511
+ /**
512
+ * The still taken at one step, as JPEG bytes.
513
+ *
514
+ * The picture shows a customer's account, so it is decrypted only on the way
515
+ * out and never cached anywhere shared.
516
+ */
517
+ getRunShot(runId, seq, signal) {
518
+ return this.requestBytes("GET", `/v1/runs/${enc(runId)}/shots/${seq}`, { signal });
519
+ }
520
+ /** What every running profile currently shows. */
521
+ getActivity(signal) {
522
+ return this.request("GET", "/v1/activity", void 0, { signal });
523
+ }
524
+ };
525
+
526
+ // src/events.ts
527
+ var MAX_BACKOFF_MS = 15e3;
528
+ function subscribeEvents(params, handlers) {
529
+ const controller = new AbortController();
530
+ const fetchImpl = params.fetch ?? globalThis.fetch;
531
+ let closed = false;
532
+ let attempt = 0;
533
+ let lastSeq = null;
534
+ let everConnected = false;
535
+ const setStatus = (s) => handlers.onStatus?.(s);
536
+ const resync = (dropped) => handlers.onEvent({ type: "resync", dropped, at: Math.floor(Date.now() / 1e3) });
537
+ const deliver = (event) => {
538
+ if (typeof event.seq === "number") {
539
+ if (lastSeq !== null && event.seq > lastSeq + 1) resync(event.seq - lastSeq - 1);
540
+ lastSeq = event.seq;
541
+ }
542
+ handlers.onEvent(event);
543
+ };
544
+ async function connect() {
545
+ while (!closed) {
546
+ setStatus("connecting");
547
+ try {
548
+ const headers = { Accept: "text/event-stream" };
549
+ if (params.token) headers["Authorization"] = `Bearer ${params.token}`;
550
+ const res = await fetchImpl(`${params.baseUrl}/v1/events`, {
551
+ headers,
552
+ signal: controller.signal
553
+ });
554
+ if (res.status === 401) {
555
+ params.onUnauthorized?.();
556
+ break;
557
+ }
558
+ if (!res.ok || !res.body) {
559
+ throw new NetworkError(`event stream failed (HTTP ${res.status})`);
560
+ }
561
+ attempt = 0;
562
+ setStatus("open");
563
+ lastSeq = null;
564
+ if (everConnected) resync(0);
565
+ everConnected = true;
566
+ await pump(res.body, deliver, controller.signal);
567
+ } catch {
568
+ if (closed || controller.signal.aborted) break;
569
+ }
570
+ if (closed) break;
571
+ setStatus("connecting");
572
+ attempt += 1;
573
+ const delay2 = Math.min(MAX_BACKOFF_MS, 500 * 2 ** Math.min(attempt, 5));
574
+ await sleep(delay2, controller.signal);
575
+ }
576
+ setStatus("closed");
577
+ }
578
+ void connect();
579
+ return () => {
580
+ closed = true;
581
+ controller.abort();
582
+ };
583
+ }
584
+ async function pump(body, onEvent, signal) {
585
+ const reader = body.getReader();
586
+ const decoder = new TextDecoder();
587
+ let buffer = "";
588
+ try {
589
+ while (!signal.aborted) {
590
+ const { value, done } = await reader.read();
591
+ if (done) break;
592
+ buffer += decoder.decode(value, { stream: true });
593
+ let sep;
594
+ while ((sep = indexOfFrameEnd(buffer)) !== -1) {
595
+ const frame = buffer.slice(0, sep);
596
+ buffer = buffer.slice(sep).replace(/^(\r?\n){1,2}/, "");
597
+ const event = parseSseFrame(frame);
598
+ if (event) onEvent(event);
599
+ }
600
+ }
601
+ } finally {
602
+ void reader.cancel().catch(() => {
603
+ });
604
+ }
605
+ }
606
+ function indexOfFrameEnd(buffer) {
607
+ const lf = buffer.indexOf("\n\n");
608
+ const crlf = buffer.indexOf("\r\n\r\n");
609
+ if (lf === -1) return crlf;
610
+ if (crlf === -1) return lf;
611
+ return Math.min(lf, crlf);
612
+ }
613
+ function parseSseFrame(frame) {
614
+ const dataLines = [];
615
+ for (const line of frame.split(/\r?\n/)) {
616
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
617
+ }
618
+ if (dataLines.length === 0) return null;
619
+ const payload = dataLines.join("\n");
620
+ if (!payload || payload === "[DONE]") return null;
621
+ try {
622
+ return JSON.parse(payload);
623
+ } catch {
624
+ return null;
625
+ }
626
+ }
627
+ function eventStream(params) {
628
+ const buffer = [];
629
+ const waiters = [];
630
+ let done = false;
631
+ const unsubscribe = subscribeEvents(params, {
632
+ onEvent: (event) => {
633
+ const waiter = waiters.shift();
634
+ if (waiter) waiter({ value: event, done: false });
635
+ else buffer.push(event);
636
+ }
637
+ });
638
+ const finish = () => {
639
+ if (!done) {
640
+ done = true;
641
+ unsubscribe();
642
+ for (const w of waiters.splice(0)) w({ value: void 0, done: true });
643
+ }
644
+ return { value: void 0, done: true };
645
+ };
646
+ return {
647
+ next() {
648
+ const next = buffer.shift();
649
+ if (next !== void 0) return Promise.resolve({ value: next, done: false });
650
+ if (done) return Promise.resolve({ value: void 0, done: true });
651
+ return new Promise((resolve) => waiters.push(resolve));
652
+ },
653
+ return() {
654
+ return Promise.resolve(finish());
655
+ },
656
+ [Symbol.asyncIterator]() {
657
+ return this;
658
+ }
659
+ };
660
+ }
661
+ function sleep(ms, signal) {
662
+ return new Promise((resolve) => {
663
+ if (signal.aborted) return resolve();
664
+ const t = setTimeout(resolve, ms);
665
+ signal.addEventListener(
666
+ "abort",
667
+ () => {
668
+ clearTimeout(t);
669
+ resolve();
670
+ },
671
+ { once: true }
672
+ );
673
+ });
674
+ }
675
+ var CdpConnection = class {
676
+ #ws;
677
+ #nextId = 0;
678
+ #pending = /* @__PURE__ */ new Map();
679
+ #subscribers = /* @__PURE__ */ new Set();
680
+ #closeListeners = /* @__PURE__ */ new Set();
681
+ #closeReason = null;
682
+ constructor(ws) {
683
+ this.#ws = ws;
684
+ ws.on("message", (data) => this.#onMessage(data));
685
+ ws.on("close", () => this.#onClose(new CdpError(-1, "CDP connection closed")));
686
+ ws.on("error", (err) => this.#onClose(new NetworkError("CDP connection error", err)));
687
+ }
688
+ request(method, params, sessionId) {
689
+ if (this.#closeReason) return Promise.reject(this.#closeReason);
690
+ const id = ++this.#nextId;
691
+ const frame = { id, method };
692
+ if (params !== void 0) frame["params"] = params;
693
+ if (sessionId !== void 0) frame["sessionId"] = sessionId;
694
+ return new Promise((resolve, reject) => {
695
+ this.#pending.set(id, { resolve, reject });
696
+ this.#ws.send(JSON.stringify(frame), (err) => {
697
+ if (err) {
698
+ this.#pending.delete(id);
699
+ reject(new NetworkError("failed to send CDP command", err));
700
+ }
701
+ });
702
+ });
703
+ }
704
+ subscribe(cb) {
705
+ this.#subscribers.add(cb);
706
+ return () => this.#subscribers.delete(cb);
707
+ }
708
+ /** Register a callback fired once when the connection closes (immediately if
709
+ * it already has) — event streams use this to end instead of hanging. */
710
+ onClose(cb) {
711
+ if (this.#closeReason) {
712
+ cb();
713
+ return () => {
714
+ };
715
+ }
716
+ this.#closeListeners.add(cb);
717
+ return () => this.#closeListeners.delete(cb);
718
+ }
719
+ close() {
720
+ this.#ws.close();
721
+ }
722
+ get closed() {
723
+ return this.#closeReason !== null;
724
+ }
725
+ #onMessage(data) {
726
+ let frame;
727
+ try {
728
+ frame = JSON.parse(rawToString(data));
729
+ } catch {
730
+ return;
731
+ }
732
+ if (typeof frame.id === "number") {
733
+ const pending = this.#pending.get(frame.id);
734
+ if (!pending) return;
735
+ this.#pending.delete(frame.id);
736
+ if (frame.error) pending.reject(new CdpError(frame.error.code, frame.error.message, frame.error.data));
737
+ else pending.resolve(frame.result);
738
+ return;
739
+ }
740
+ if (typeof frame.method === "string") {
741
+ const event = { method: frame.method, params: frame.params ?? {}, sessionId: frame.sessionId };
742
+ for (const cb of [...this.#subscribers]) cb(event);
743
+ }
744
+ }
745
+ #onClose(reason) {
746
+ if (this.#closeReason) return;
747
+ this.#closeReason = reason;
748
+ for (const [, pending] of this.#pending) pending.reject(reason);
749
+ this.#pending.clear();
750
+ this.#subscribers.clear();
751
+ for (const cb of [...this.#closeListeners]) cb();
752
+ this.#closeListeners.clear();
753
+ }
754
+ };
755
+ function rawToString(data) {
756
+ if (typeof data === "string") return data;
757
+ if (Buffer.isBuffer(data)) return data.toString("utf8");
758
+ if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
759
+ return Buffer.from(data).toString("utf8");
760
+ }
761
+ var CdpSession = class _CdpSession {
762
+ #conn;
763
+ sessionId;
764
+ #client;
765
+ #profileId;
766
+ #isolatedContextId;
767
+ constructor(conn, sessionId, client, profileId) {
768
+ this.#conn = conn;
769
+ this.sessionId = sessionId;
770
+ this.#client = client;
771
+ this.#profileId = profileId;
772
+ }
773
+ /** Send a raw CDP command and await its result (by id). */
774
+ send(method, params) {
775
+ return this.#conn.request(method, params, this.sessionId);
776
+ }
777
+ /** Register a callback for a CDP event scoped to this session. Returns unsubscribe. */
778
+ on(method, callback) {
779
+ return this.#conn.subscribe((event) => {
780
+ if (event.method === method && event.sessionId === this.sessionId) callback(event.params);
781
+ });
782
+ }
783
+ /** `for await (const event of session.events())` over this session's CDP events. */
784
+ events() {
785
+ return scopedEventStream(this.#conn, this.sessionId);
786
+ }
787
+ /** Attach to a page target, returning a page-bound child session. */
788
+ async attachToPage(opts = {}) {
789
+ let targetId = opts.targetId;
790
+ if (opts.create) {
791
+ const created = await this.send("Target.createTarget", { url: opts.url ?? "about:blank" });
792
+ targetId = created.targetId;
793
+ } else if (!targetId) {
794
+ const { targetInfos } = await this.send("Target.getTargets");
795
+ const page = targetInfos.find((t) => t.type === "page");
796
+ if (page) {
797
+ targetId = page.targetId;
798
+ } else {
799
+ const created = await this.send("Target.createTarget", { url: opts.url ?? "about:blank" });
800
+ targetId = created.targetId;
801
+ }
802
+ }
803
+ const { sessionId } = await this.send("Target.attachToTarget", { targetId, flatten: true });
804
+ return new _CdpSession(this.#conn, sessionId, this.#client, this.#profileId);
805
+ }
806
+ /** Navigate the page; optionally wait for load. Never calls `Runtime.enable`. */
807
+ async navigate(url, opts = {}) {
808
+ const res = await this.send("Page.navigate", { url });
809
+ if (res.errorText) throw new CdpError(-1, `navigation failed: ${res.errorText}`);
810
+ if (opts.wait !== false) await this.#waitForLoad(opts.timeout ?? 3e4);
811
+ return res;
812
+ }
813
+ /** Evaluate a JS expression in the page (or an isolated world) and return its value. */
814
+ async evaluate(expression, opts = {}) {
815
+ const params = {
816
+ expression,
817
+ awaitPromise: opts.awaitPromise ?? true,
818
+ returnByValue: opts.returnByValue ?? true
819
+ };
820
+ if (opts.isolated) params["contextId"] = await this.#isolatedContext();
821
+ const res = await this.send("Runtime.evaluate", params);
822
+ if (res.exceptionDetails) {
823
+ throw new CdpError(-1, res.exceptionDetails.text || res.exceptionDetails.exception?.description || "evaluate exception");
824
+ }
825
+ return res.result.value;
826
+ }
827
+ /** Create an isolated world for the (main) frame and return its execution context id. */
828
+ async createIsolatedWorld(frameId, worldName = "__sb") {
829
+ let frame = frameId;
830
+ if (!frame) {
831
+ const { frameTree } = await this.send("Page.getFrameTree");
832
+ frame = frameTree.frame.id;
833
+ }
834
+ const { executionContextId } = await this.send("Page.createIsolatedWorld", {
835
+ frameId: frame,
836
+ worldName,
837
+ grantUniveralAccess: false
838
+ });
839
+ return executionContextId;
840
+ }
841
+ // ── Humanized trusted input (routes through the daemon, G8) ────────────────
842
+ humanizeMove(x, y, opts = {}) {
843
+ return this.#input({ action: "move", x, y, width: opts.width });
844
+ }
845
+ humanizeClick(x, y, opts = {}) {
846
+ return this.#input({
847
+ action: "click",
848
+ x,
849
+ y,
850
+ button: opts.button ?? "left",
851
+ click_count: opts.clickCount ?? 1,
852
+ width: opts.width
853
+ });
854
+ }
855
+ /**
856
+ * B5: humanized move to the CENTRE of an element box, with the box width fed to the
857
+ * daemon's Fitts-law approach so the pointer lands *within* the element (not on a
858
+ * fixed corner). Pass a `getBoundingClientRect()`-shaped box.
859
+ */
860
+ humanizeMoveToElement(box) {
861
+ return this.humanizeMove(box.x + box.width / 2, box.y + box.height / 2, { width: box.width });
862
+ }
863
+ /** B5: humanized click at the centre of an element box (see {@link humanizeMoveToElement}). */
864
+ humanizeClickElement(box, opts = {}) {
865
+ return this.humanizeClick(box.x + box.width / 2, box.y + box.height / 2, { ...opts, width: box.width });
866
+ }
867
+ humanizeType(text) {
868
+ return this.#input({ action: "type", text });
869
+ }
870
+ humanizeScroll(x, y, opts = {}) {
871
+ return this.#input({ action: "scroll", x, y, delta_x: opts.deltaX ?? 0, delta_y: opts.deltaY ?? 0 });
872
+ }
873
+ /** Close the underlying CDP connection. */
874
+ close() {
875
+ this.#conn.close();
876
+ }
877
+ #input(body) {
878
+ if (!this.#client || !this.#profileId) {
879
+ return Promise.reject(
880
+ new CdpError(-1, "humanize requires a CDP session created via client.connectCdp(result, profileId)")
881
+ );
882
+ }
883
+ return this.#client.sendInput(this.#profileId, { ...body, humanize: true });
884
+ }
885
+ async #isolatedContext() {
886
+ if (this.#isolatedContextId === void 0) this.#isolatedContextId = await this.createIsolatedWorld();
887
+ return this.#isolatedContextId;
888
+ }
889
+ async #waitForLoad(timeout) {
890
+ const deadline = Date.now() + timeout;
891
+ for (; ; ) {
892
+ const ready = await this.evaluate("document.readyState");
893
+ if (ready === "complete") return;
894
+ if (Date.now() > deadline) throw new CdpError(-1, "navigation wait timed out");
895
+ await delay(50);
896
+ }
897
+ }
898
+ };
899
+ async function openCdpSession(url, options = {}) {
900
+ const headers = {};
901
+ if (options.token) headers["Authorization"] = `Bearer ${options.token}`;
902
+ const ws = new WebSocket(url, { headers, handshakeTimeout: options.openTimeout ?? 3e4 });
903
+ await waitForOpen(ws);
904
+ const conn = new CdpConnection(ws);
905
+ const root = new CdpSession(conn, void 0, options.client, options.profileId);
906
+ if (options.autoAttach === false) return root;
907
+ return root.attachToPage({ create: options.createTarget ?? false, url: options.targetUrl });
908
+ }
909
+ function waitForOpen(ws) {
910
+ return new Promise((resolve, reject) => {
911
+ const onOpen = () => {
912
+ cleanup();
913
+ resolve();
914
+ };
915
+ const onError = (err) => {
916
+ cleanup();
917
+ reject(new NetworkError("CDP websocket failed to open", err));
918
+ };
919
+ const cleanup = () => {
920
+ ws.off("open", onOpen);
921
+ ws.off("error", onError);
922
+ };
923
+ ws.on("open", onOpen);
924
+ ws.on("error", onError);
925
+ });
926
+ }
927
+ function scopedEventStream(conn, sessionId) {
928
+ const buffer = [];
929
+ const waiters = [];
930
+ let done = false;
931
+ const unsubscribe = conn.subscribe((event) => {
932
+ if (event.sessionId !== sessionId) return;
933
+ const waiter = waiters.shift();
934
+ if (waiter) waiter({ value: event, done: false });
935
+ else buffer.push(event);
936
+ });
937
+ let offClose = () => {
938
+ };
939
+ const finish = () => {
940
+ if (!done) {
941
+ done = true;
942
+ unsubscribe();
943
+ offClose();
944
+ for (const w of waiters.splice(0)) w({ value: void 0, done: true });
945
+ }
946
+ return { value: void 0, done: true };
947
+ };
948
+ offClose = conn.onClose(() => {
949
+ finish();
950
+ });
951
+ return {
952
+ next() {
953
+ const next = buffer.shift();
954
+ if (next !== void 0) return Promise.resolve({ value: next, done: false });
955
+ if (done) return Promise.resolve({ value: void 0, done: true });
956
+ return new Promise((resolve) => waiters.push(resolve));
957
+ },
958
+ return() {
959
+ return Promise.resolve(finish());
960
+ },
961
+ [Symbol.asyncIterator]() {
962
+ return this;
963
+ }
964
+ };
965
+ }
966
+ function delay(ms) {
967
+ return new Promise((resolve) => setTimeout(resolve, ms));
968
+ }
969
+
970
+ // src/client.ts
971
+ var DEFAULT_CAPACITY = {
972
+ max_concurrent: 64,
973
+ ram_budget_mb: 24576,
974
+ vram_budget_mb: null
975
+ };
976
+ var ScalebrowserClient = class extends ClientCore {
977
+ // ── Profiles ───────────────────────────────────────────────────────────────
978
+ listProfiles(params = {}, signal) {
979
+ return this.request("GET", "/v1/profiles", void 0, {
980
+ query: {
981
+ group: params.group,
982
+ state: params.state,
983
+ q: params.q,
984
+ limit: params.limit,
985
+ offset: params.offset,
986
+ sort: params.sort,
987
+ order: params.order
988
+ },
989
+ signal
990
+ });
991
+ }
992
+ /**
993
+ * Every id matching `params`, unpaged — what "act on all matches" needs.
994
+ *
995
+ * `listProfiles` is paged, so acting on "everything" from it only ever covers the
996
+ * page you fetched. Asking for ids costs one request and no persona payloads.
997
+ */
998
+ listProfileIds(params = {}, signal) {
999
+ return this.request("GET", "/v1/profiles/ids", void 0, {
1000
+ query: {
1001
+ group: params.group,
1002
+ state: params.state,
1003
+ q: params.q,
1004
+ sort: params.sort,
1005
+ order: params.order
1006
+ },
1007
+ signal
1008
+ });
1009
+ }
1010
+ getProfile(id, signal) {
1011
+ return this.request("GET", `/v1/profiles/${enc(id)}`, void 0, { signal });
1012
+ }
1013
+ createProfile(body) {
1014
+ return this.request("POST", "/v1/profiles", body);
1015
+ }
1016
+ updateProfile(id, body) {
1017
+ return this.request("PATCH", `/v1/profiles/${enc(id)}`, body);
1018
+ }
1019
+ deleteProfile(id) {
1020
+ return this.request("DELETE", `/v1/profiles/${enc(id)}`);
1021
+ }
1022
+ /** Start a profile → CDP ws endpoint. Errors: 4002/4003/4005/4006/4008. */
1023
+ startProfile(id, body = {}) {
1024
+ return this.request("POST", `/v1/profiles/${enc(id)}/start`, body);
1025
+ }
1026
+ stopProfile(id) {
1027
+ return this.request("POST", `/v1/profiles/${enc(id)}/stop`, {});
1028
+ }
1029
+ // ── Bulk (PRD §D2) ───────────────────────────────────────────────────────
1030
+ bulkCreateProfiles(body) {
1031
+ return this.request("POST", "/v1/bulk/profiles", body);
1032
+ }
1033
+ /**
1034
+ * Start a batch. `headless` is the ONE visibility decision for the run; omitted
1035
+ * means headless, the right default for an unattended client. The daemon stops at
1036
+ * the machine's capacity limit and reports how far it got (`started` /
1037
+ * `remaining` / `stopped_reason`) instead of failing every remaining id.
1038
+ */
1039
+ bulkStart(ids, headless) {
1040
+ return this.request("POST", "/v1/bulk/start", { ids, headless });
1041
+ }
1042
+ bulkStop(ids) {
1043
+ return this.request("POST", "/v1/bulk/stop", { ids });
1044
+ }
1045
+ bulkDelete(ids) {
1046
+ return this.request("POST", "/v1/bulk/delete", { ids });
1047
+ }
1048
+ bulkAssignProxy(ids, proxyId) {
1049
+ return this.request("POST", "/v1/bulk/assign-proxy", { ids, proxy_id: proxyId });
1050
+ }
1051
+ /**
1052
+ * Give every id exactly this set of extensions.
1053
+ *
1054
+ * It REPLACES rather than adds, the same shape as assigning a proxy: picking a
1055
+ * set for a batch states what those profiles carry. An empty list is a
1056
+ * legitimate "none". An id the library does not hold refuses the whole call.
1057
+ */
1058
+ bulkAssignExtensions(ids, extRefs) {
1059
+ return this.request("POST", "/v1/bulk/assign-extensions", { ids, ext_refs: extRefs });
1060
+ }
1061
+ // ── Groups & presets ───────────────────────────────────────────────────────
1062
+ listGroups(signal) {
1063
+ return this.request("GET", "/v1/groups", void 0, { signal });
1064
+ }
1065
+ getGroup(id, signal) {
1066
+ return this.request("GET", `/v1/groups/${enc(id)}`, void 0, { signal });
1067
+ }
1068
+ createGroup(body) {
1069
+ return this.request("POST", "/v1/groups", body);
1070
+ }
1071
+ updateGroup(id, body) {
1072
+ return this.request("PATCH", `/v1/groups/${enc(id)}`, body);
1073
+ }
1074
+ deleteGroup(id) {
1075
+ return this.request("DELETE", `/v1/groups/${enc(id)}`);
1076
+ }
1077
+ listPresets(signal) {
1078
+ return this.request("GET", "/v1/presets", void 0, { signal });
1079
+ }
1080
+ getPreset(id, signal) {
1081
+ return this.request("GET", `/v1/presets/${enc(id)}`, void 0, { signal });
1082
+ }
1083
+ createPreset(body) {
1084
+ return this.request("POST", "/v1/presets", body);
1085
+ }
1086
+ updatePreset(id, body) {
1087
+ return this.request("PATCH", `/v1/presets/${enc(id)}`, body);
1088
+ }
1089
+ deletePreset(id) {
1090
+ return this.request("DELETE", `/v1/presets/${enc(id)}`);
1091
+ }
1092
+ /**
1093
+ * The values a preset's `constraints` may take. Ask rather than hardcode: the
1094
+ * region list comes from the daemon's embedded persona model, so a pinned copy
1095
+ * drifts the moment that model changes and every create 400s.
1096
+ */
1097
+ getPersonaConstraints(signal) {
1098
+ return this.request(
1099
+ "GET",
1100
+ "/v1/persona/constraints",
1101
+ void 0,
1102
+ { signal }
1103
+ );
1104
+ }
1105
+ // ── Proxies (credentials write-only) ────────────────────────────────────────
1106
+ listProxies(signal) {
1107
+ return this.request("GET", "/v1/proxies", void 0, { signal });
1108
+ }
1109
+ getProxy(id, signal) {
1110
+ return this.request("GET", `/v1/proxies/${enc(id)}`, void 0, { signal });
1111
+ }
1112
+ createProxy(body) {
1113
+ return this.request("POST", "/v1/proxies", body);
1114
+ }
1115
+ updateProxy(id, body) {
1116
+ return this.request("PATCH", `/v1/proxies/${enc(id)}`, body);
1117
+ }
1118
+ deleteProxy(id) {
1119
+ return this.request("DELETE", `/v1/proxies/${enc(id)}`);
1120
+ }
1121
+ /** Health + geo check (exit-ip/country/healthy + JA4↔JA4T verdict). */
1122
+ checkProxy(id) {
1123
+ return this.request("POST", `/v1/proxies/${enc(id)}/check`, {});
1124
+ }
1125
+ /**
1126
+ * Probe a proxy config WITHOUT persisting it — validate before you commit.
1127
+ * Pass `id` (and omit the credentials) to probe an existing proxy's stored,
1128
+ * write-only credentials in an edit form.
1129
+ */
1130
+ checkProxyConfig(body) {
1131
+ return this.request("POST", "/v1/proxies/check", body);
1132
+ }
1133
+ // ── Live detector audit ─────────────────────────────────────────────────────
1134
+ //
1135
+ // Drives the profile's OWN running browser through ~15 third-party fingerprint
1136
+ // sites and keeps what each one saw. The profile must be RUNNING; the run takes
1137
+ // minutes, so `startAudit` returns immediately and you poll `getAudit`.
1138
+ /** Queue a run → the status right after queueing. `409` if not running / already running. */
1139
+ startAudit(profileId) {
1140
+ return this.request("POST", `/v1/profiles/${enc(profileId)}/audit`);
1141
+ }
1142
+ /** State plus the last stored report. */
1143
+ getAudit(profileId, signal) {
1144
+ return this.request("GET", `/v1/profiles/${enc(profileId)}/audit`, void 0, {
1145
+ signal
1146
+ });
1147
+ }
1148
+ // ── Session bundles (AC-ST-002/003) ─────────────────────────────────────────
1149
+ exportSession(profileId, body) {
1150
+ return this.request("POST", `/v1/profiles/${enc(profileId)}/session/export`, body);
1151
+ }
1152
+ importSession(profileId, body) {
1153
+ return this.request("POST", `/v1/profiles/${enc(profileId)}/session/import`, body);
1154
+ }
1155
+ // ── Trusted input (G8, AC-API-003) ─────────────────────────────────────────
1156
+ sendInput(profileId, body) {
1157
+ return this.request("POST", `/v1/profiles/${enc(profileId)}/input`, body);
1158
+ }
1159
+ // ── Metrics (with graceful fallback) ────────────────────────────────────────
1160
+ async getMetrics(signal) {
1161
+ try {
1162
+ return await this.request("GET", "/v1/metrics", void 0, { signal });
1163
+ } catch (err) {
1164
+ if (err instanceof ApiError && (err.status === 404 || err.code === 4001)) {
1165
+ return deriveMetricsFromProfiles(await this.listProfiles({ limit: 1e4 }, signal));
1166
+ }
1167
+ throw err;
1168
+ }
1169
+ }
1170
+ // ── Events (SSE) ───────────────────────────────────────────────────────────
1171
+ /** Subscribe to lifecycle events. Returns an unsubscribe function. */
1172
+ subscribeEvents(handlers) {
1173
+ return subscribeEvents({ baseUrl: this.baseUrl, token: this.token, fetch: this.fetchImpl }, handlers);
1174
+ }
1175
+ /** `for await (const event of client.events())` over lifecycle events. */
1176
+ events() {
1177
+ return eventStream({ baseUrl: this.baseUrl, token: this.token, fetch: this.fetchImpl });
1178
+ }
1179
+ // ── Direct-CDP driver plane ─────────────────────────────────────────────────
1180
+ /**
1181
+ * Open a direct-CDP session against a started profile's `cdp_ws`. Pass the
1182
+ * `profileId` so `humanize*` helpers can route trusted input through the daemon.
1183
+ */
1184
+ connectCdp(target, profileId) {
1185
+ const url = typeof target === "string" ? target : target.cdp_ws;
1186
+ return openCdpSession(url, { token: this.token, client: this, profileId });
1187
+ }
1188
+ /**
1189
+ * Start a profile, connect direct-CDP, and return a {@link LaunchHandle} whose
1190
+ * `stop()` (or `await using`) closes CDP and stops the profile.
1191
+ */
1192
+ async launch(profileId, body = {}) {
1193
+ const result = await this.startProfile(profileId, { headless: body.headless ?? true });
1194
+ const cdp = await this.connectCdp(result, profileId);
1195
+ const stop = async () => {
1196
+ cdp.close();
1197
+ await this.stopProfile(profileId);
1198
+ };
1199
+ return { cdp, result, stop, [Symbol.asyncDispose]: stop };
1200
+ }
1201
+ };
1202
+ function deriveMetricsFromProfiles(profiles) {
1203
+ const running = profiles.filter((p) => p.runtime_state === "running").length;
1204
+ return {
1205
+ running,
1206
+ capacity_max_concurrent: DEFAULT_CAPACITY.max_concurrent,
1207
+ ram_used_mb: null,
1208
+ ram_budget_mb: DEFAULT_CAPACITY.ram_budget_mb,
1209
+ vram_used_mb: null,
1210
+ vram_budget_mb: DEFAULT_CAPACITY.vram_budget_mb,
1211
+ cpu_pct: null,
1212
+ gpu_pct: null,
1213
+ // Mark the fallback derived so consumers can tell it from a live sample
1214
+ // whose probes happen to be unavailable.
1215
+ source: "derived",
1216
+ sampled_at: null,
1217
+ availability: {
1218
+ ram: "unavailable",
1219
+ cpu: "unavailable",
1220
+ gpu: "unavailable",
1221
+ vram: "unavailable",
1222
+ per_profile: "unavailable"
1223
+ },
1224
+ profiles: []
1225
+ };
1226
+ }
1227
+ applyMixins(ScalebrowserClient, [IdentityApi, RunsApi, ControlApi, ExtensionsApi]);
1228
+
1229
+ // src/types.ts
1230
+ var RUNTIME_STATES = ["stopped", "starting", "running", "crashed"];
1231
+ var HOST_MODES = ["linux_engine_spoof", "local_native_windows"];
1232
+ var GEO_MODES = ["strict_expected", "follow_exit", "off"];
1233
+ var PROXY_KINDS = ["http", "socks5", "gateway"];
1234
+ var ROTATIONS = ["sticky", "rotating"];
1235
+
1236
+ // src/index.ts
1237
+ var VERSION = "0.2.0";
1238
+
1239
+ export { ApiError, CdpError, CdpSession, DEFAULT_CAPACITY, ErrorCode, GEO_MODES, HOST_MODES, NetworkError, PROXY_KINDS, ROTATIONS, RUNTIME_STATES, ScalebrowserClient, ScalebrowserError, VERSION, deriveMetricsFromProfiles, errorMessage, eventStream, openCdpSession, parseSseFrame, subscribeEvents };