@steerpane/sdk 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +109 -2
- package/dist/client.d.ts +409 -0
- package/dist/client.js +506 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +49 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/playwright.d.ts +16 -0
- package/dist/playwright.js +20 -0
- package/dist/types.d.ts +454 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/dist/webhooks.d.ts +17 -0
- package/dist/webhooks.js +64 -0
- package/package.json +29 -4
package/dist/client.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { APIError, errorFromResponse, SteerpaneError, ToolError } from "./errors.js";
|
|
4
|
+
import { VERSION } from "./version.js";
|
|
5
|
+
export const DEFAULT_BASE_URL = "https://api.steerpane.app";
|
|
6
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
7
|
+
const RETRY_STATUSES = new Set([502, 503, 504]);
|
|
8
|
+
const MCP_PROTOCOL = "2025-06-18";
|
|
9
|
+
const seg = (value) => encodeURIComponent(String(value));
|
|
10
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
const clean = (values) => Object.fromEntries(Object.entries(values).filter(([, v]) => v !== undefined && v !== null));
|
|
12
|
+
const retryAfterOf = (res) => {
|
|
13
|
+
const v = Number(res.headers.get("retry-after"));
|
|
14
|
+
return res.headers.has("retry-after") && Number.isFinite(v) ? v : null;
|
|
15
|
+
};
|
|
16
|
+
async function parse(res) {
|
|
17
|
+
const text = await res.text();
|
|
18
|
+
if (!text)
|
|
19
|
+
return null;
|
|
20
|
+
if ((res.headers.get("content-type") ?? "").includes("json"))
|
|
21
|
+
return JSON.parse(text);
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
/** Browser actions, `POST /v1/sessions/{id}/actions`. While a human holds control, acting calls throw `HumanInControlError` and queue. */
|
|
25
|
+
class Actions {
|
|
26
|
+
c;
|
|
27
|
+
constructor(c) {
|
|
28
|
+
this.c = c;
|
|
29
|
+
}
|
|
30
|
+
/** Run any action type. `waitS` holds the call until a takeover ends instead of throwing, up to 600 seconds. */
|
|
31
|
+
run(sessionId, type, args = {}, options = {}) {
|
|
32
|
+
const headers = options.waitS === undefined ? {} : { "Steerpane-Wait": String(options.waitS) };
|
|
33
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/actions`, { json: { type, ...clean(args) }, headers, timeoutMs: Math.max(this.c.timeoutMs, ((options.waitS ?? 0) + 30) * 1000) });
|
|
34
|
+
}
|
|
35
|
+
navigate(sessionId, url, args = {}) { return this.run(sessionId, "navigate", { url, ...args }); }
|
|
36
|
+
click(sessionId, selector, args = {}) { return this.run(sessionId, "click", { selector, ...args }); }
|
|
37
|
+
type(sessionId, selector, text, args = {}) { return this.run(sessionId, "type", { selector, text, ...args }); }
|
|
38
|
+
pressKey(sessionId, key, args = {}) { return this.run(sessionId, "press_key", { key, ...args }); }
|
|
39
|
+
selectOption(sessionId, selector, option, args = {}) { return this.run(sessionId, "select_option", { selector, option, ...args }); }
|
|
40
|
+
scroll(sessionId, args = {}) { return this.run(sessionId, "scroll", args); }
|
|
41
|
+
/** Exactly one of `selector` (+ `state`), `text`, `url`, `load_state` or `ms`. `timeout_ms` defaults to 10000. */
|
|
42
|
+
waitFor(sessionId, args) { return this.run(sessionId, "wait_for", args); }
|
|
43
|
+
snapshot(sessionId, args = {}) { return this.run(sessionId, "snapshot", args); }
|
|
44
|
+
getPageText(sessionId, args = {}) { return this.run(sessionId, "get_page_text", args); }
|
|
45
|
+
elementState(sessionId, selector, args = {}) { return this.run(sessionId, "element_state", { selector, ...args }); }
|
|
46
|
+
formFields(sessionId, args = {}) { return this.run(sessionId, "form_fields", args); }
|
|
47
|
+
screenshot(sessionId, args = {}) { return this.run(sessionId, "screenshot", args); }
|
|
48
|
+
/** Run JavaScript in the page. `code` is a function body, so use `return` to get a value back as `result`. */
|
|
49
|
+
execute(sessionId, code, args = {}) { return this.run(sessionId, "execute", { code, ...args }); }
|
|
50
|
+
/** Needs a model key on the organization. */
|
|
51
|
+
extract(sessionId, instruction, args = {}) { return this.run(sessionId, "extract", { instruction, ...args }); }
|
|
52
|
+
download(sessionId, args = {}) { return this.run(sessionId, "download", args); }
|
|
53
|
+
uploadFile(sessionId, args = {}) { return this.run(sessionId, "upload_file", args); }
|
|
54
|
+
/** Type a value you hold (a password, an access code) into ONE field of the page, on the site you name. `POST /v1/sessions/{id}/secret-fill`; needs the `vault:use` scope.
|
|
55
|
+
* The runtime checks the origin of the frame holding the field against `site` in the same step that types (`exact: true`: the host must match exactly, no subdomains).
|
|
56
|
+
* `kind` is `password` (default; the field must be a password input) or `code`. `submit` and `exact` must be real booleans.
|
|
57
|
+
*
|
|
58
|
+
* The value is never logged, recorded, queued or returned (the result shows `value: "••••••"`). The SDK doesn't echo it either, and this call is NEVER retried: no
|
|
59
|
+
* automatic retry, no Idempotency-Key (the API refuses one). Treat every failure that isn't a definitive refusal before typing (`origin_mismatch`, `insecure_origin`,
|
|
60
|
+
* `field_type_mismatch`, `element_not_found`, ...) as "maybe typed": look at the page before you send it again.
|
|
61
|
+
* **`typed: true` in the result, or a 409 with `details.typed`, means the value is already in the page: do not send it again.** A human in control or a paused agent
|
|
62
|
+
* answers 409 `human_in_control` with nothing typed; a secret is never queued. */
|
|
63
|
+
secretFill(sessionId, input) {
|
|
64
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/secret-fill`, { json: clean(input) });
|
|
65
|
+
}
|
|
66
|
+
/** Type a stored credential into the page. The secret never comes back. Refused off the credential's site. */
|
|
67
|
+
vaultFill(sessionId, credentialId, args = {}) { return this.run(sessionId, "vault_fill", { credential_id: credentialId, ...args }); }
|
|
68
|
+
}
|
|
69
|
+
class Sessions {
|
|
70
|
+
c;
|
|
71
|
+
constructor(c) {
|
|
72
|
+
this.c = c;
|
|
73
|
+
}
|
|
74
|
+
list(params = {}) {
|
|
75
|
+
return this.c.request("GET", "/v1/sessions", { params });
|
|
76
|
+
}
|
|
77
|
+
/** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `end_user_id`, `auto_close`, `timeout_min`.
|
|
78
|
+
* `profile_wait: { timeout_s: 300 }` queues behind the profile's current writer instead of failing with 409 `profile_locked`: the session comes back with
|
|
79
|
+
* `status: "queued"` and `queue_position`; stop it to cancel. Without `profile_id` you get a throwaway browser that saves nothing. */
|
|
80
|
+
create(config = {}, options = {}) {
|
|
81
|
+
return this.c.request("POST", "/v1/sessions", { json: clean(config), headers: { "Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID() } });
|
|
82
|
+
}
|
|
83
|
+
get(sessionId) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}`); }
|
|
84
|
+
/** Wait for the browser to leave `starting` (and `queued`, for a session created with `profile_wait`: raise `timeoutS` to cover the wait). Throws if it ends instead, or doesn't start in time. */
|
|
85
|
+
async waitUntilReady(sessionId, options = {}) {
|
|
86
|
+
const timeoutS = options.timeoutS ?? 90;
|
|
87
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
88
|
+
for (;;) {
|
|
89
|
+
const session = await this.get(sessionId);
|
|
90
|
+
if (session.status === "stopped" || session.status === "error")
|
|
91
|
+
throw new SteerpaneError(`Session ${sessionId} ended before it was ready: ${session.error_message ?? session.status}`);
|
|
92
|
+
if (session.status !== "starting" && session.status !== "queued")
|
|
93
|
+
return session;
|
|
94
|
+
if (Date.now() >= deadline)
|
|
95
|
+
throw new SteerpaneError(`Session ${sessionId} was still ${session.status} after ${timeoutS} s`);
|
|
96
|
+
await sleep((options.pollS ?? 0.5) * 1000);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
stop(sessionId) { return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/stop`); }
|
|
100
|
+
async delete(sessionId) { await this.c.request("DELETE", `/v1/sessions/${seg(sessionId)}`); }
|
|
101
|
+
keepalive(sessionId) { return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/keepalive`); }
|
|
102
|
+
takeover(sessionId) { return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/takeover`); }
|
|
103
|
+
/** Give control back. The waiting agent receives `outcome` and `note`. */
|
|
104
|
+
handback(sessionId, input = {}) {
|
|
105
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/handback`, { json: clean(input) });
|
|
106
|
+
}
|
|
107
|
+
/** Name what the agent may not click or submit without approval. `rules` replaces the current ones; `[]` removes the guard. A blocked action fails with the
|
|
108
|
+
* `action_guard_blocked` error code until `unlockAction` is called. The service ships no rules of its own. */
|
|
109
|
+
setActionGuard(sessionId, rules) { return this.c.request("PUT", `/v1/sessions/${seg(sessionId)}/action-guard`, { json: { rules } }); }
|
|
110
|
+
getActionGuard(sessionId) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}/action-guard`); }
|
|
111
|
+
async removeActionGuard(sessionId) { await this.c.request("DELETE", `/v1/sessions/${seg(sessionId)}/action-guard`); }
|
|
112
|
+
/** Approve ONE blocked gesture (the next matching click, Enter-submit or form submit, optionally only for `ruleId`) within `ttlS` seconds (1 to 120, default 30). Needs the `takeover` scope.
|
|
113
|
+
* The result's `late_targets` counts frames where a page script may have registered a listener ahead of the guard; if you need certainty, decline to rely on the guard when it is above 0.
|
|
114
|
+
* An action that spends the unlock returns `unlock_spent: true`, and an error it ends with carries `details.unlock_spent: true` (read it from `APIError.details`/`ToolError.details`): the act may have happened, so never click again, whatever the error.
|
|
115
|
+
* A session whose guard can't be installed refuses guarded actions with `action_guard_unavailable`. */
|
|
116
|
+
unlockAction(sessionId, options = {}) {
|
|
117
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/action-guard/unlock`, { json: clean({ rule_id: options.ruleId, ttl_s: options.ttlS }) });
|
|
118
|
+
}
|
|
119
|
+
/** Run the CAPTCHA solver on the session's page. One call waits about 45 s: while `status` is `in_progress`, call again (the worker keeps running, it never restarts).
|
|
120
|
+
* `onFailure` is `wait_for_human` (default), `request_help` or `return`. Needs a CAPTCHA model on the organization. */
|
|
121
|
+
solveCaptcha(sessionId, options = {}) {
|
|
122
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/captcha/solve`, {
|
|
123
|
+
json: clean({ on_failure: options.onFailure, max_reloads: options.maxReloads, model: options.model, captcha_image_selector: options.captchaImageSelector, captcha_input_selector: options.captchaInputSelector }),
|
|
124
|
+
timeoutMs: Math.max(this.c.timeoutMs, 90_000),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Ask a person for help. The agent goes on hold, and `agent.human_help_requested` fires. */
|
|
128
|
+
requestHelp(sessionId, input) {
|
|
129
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/help-requests`, { json: { message: "", ...input } });
|
|
130
|
+
}
|
|
131
|
+
/** Long-poll until the human hands back. `status` is `handed_back`, `timeout` (call again) or `stopped`. */
|
|
132
|
+
waitForHuman(sessionId, options = {}) {
|
|
133
|
+
const timeoutS = options.timeoutS ?? 30;
|
|
134
|
+
return this.c.request("GET", `/v1/sessions/${seg(sessionId)}/help-requests/${seg(options.requestId ?? "latest")}/wait`, {
|
|
135
|
+
params: { timeout_s: timeoutS }, timeoutMs: Math.max(this.c.timeoutMs, (timeoutS + 30) * 1000),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
events(sessionId, params = {}) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}/events`, { params }); }
|
|
139
|
+
files(sessionId) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}/files`); }
|
|
140
|
+
/** Pack what the session downloaded into one ZIP in the file store. */
|
|
141
|
+
zipFiles(sessionId) { return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/files/zip`); }
|
|
142
|
+
}
|
|
143
|
+
class Profiles {
|
|
144
|
+
c;
|
|
145
|
+
constructor(c) {
|
|
146
|
+
this.c = c;
|
|
147
|
+
}
|
|
148
|
+
list(params = {}) { return this.c.request("GET", "/v1/profiles", { params }); }
|
|
149
|
+
get(profileId) { return this.c.request("GET", `/v1/profiles/${seg(profileId)}`); }
|
|
150
|
+
/** `fields` include `end_user_id` to give the profile to one of your end users. */
|
|
151
|
+
create(name, fields = {}) { return this.c.request("POST", "/v1/profiles", { json: { name, ...clean(fields) } }); }
|
|
152
|
+
update(profileId, fields) { return this.c.request("PATCH", `/v1/profiles/${seg(profileId)}`, { json: fields }); }
|
|
153
|
+
async delete(profileId) { await this.c.request("DELETE", `/v1/profiles/${seg(profileId)}`); }
|
|
154
|
+
clone(profileId) { return this.c.request("POST", `/v1/profiles/${seg(profileId)}/clone`); }
|
|
155
|
+
/** Really load each signed-in site with the profile's state, and report whether it is still signed in. */
|
|
156
|
+
verify(profileId, input = {}) { return this.c.request("POST", `/v1/profiles/${seg(profileId)}/verify`, { json: clean(input), timeoutMs: Math.max(this.c.timeoutMs, 90_000) }); }
|
|
157
|
+
lock(profileId) { return this.c.request("GET", `/v1/profiles/${seg(profileId)}/lock`); }
|
|
158
|
+
importCookies(profileId, cookies) { return this.c.request("POST", `/v1/profiles/${seg(profileId)}/cookies/import`, { json: { cookies } }); }
|
|
159
|
+
}
|
|
160
|
+
/** Your own customers. Profiles and sessions can belong to one; a session only uses profiles of its own end user. */
|
|
161
|
+
class EndUsers {
|
|
162
|
+
c;
|
|
163
|
+
constructor(c) {
|
|
164
|
+
this.c = c;
|
|
165
|
+
}
|
|
166
|
+
/** Idempotent on `external_id`: calling it again returns the existing end user unchanged. */
|
|
167
|
+
create(externalId, fields = {}) {
|
|
168
|
+
return this.c.request("POST", "/v1/end-users", { json: clean({ external_id: externalId, ...fields }) });
|
|
169
|
+
}
|
|
170
|
+
list(params = {}) { return this.c.request("GET", "/v1/end-users", { params }); }
|
|
171
|
+
get(endUserId) { return this.c.request("GET", `/v1/end-users/${seg(endUserId)}`); }
|
|
172
|
+
update(endUserId, fields) {
|
|
173
|
+
return this.c.request("PATCH", `/v1/end-users/${seg(endUserId)}`, { json: clean(fields) });
|
|
174
|
+
}
|
|
175
|
+
/** 409 while it owns profiles or live sessions; `cascade: true` deletes its profiles too (never while a session is live). */
|
|
176
|
+
async delete(endUserId, options = {}) {
|
|
177
|
+
await this.c.request("DELETE", `/v1/end-users/${seg(endUserId)}`, { params: options.cascade ? { cascade: "true" } : {} });
|
|
178
|
+
}
|
|
179
|
+
/** A single-use code (valid a few minutes) to hand to this end user so they can pair their own computer. Answers 404 while devices are off for the service. */
|
|
180
|
+
createDevicePairingCode(endUserId) { return this.c.request("POST", `/v1/end-users/${seg(endUserId)}/device-pairing-codes`); }
|
|
181
|
+
}
|
|
182
|
+
/** Computers that run sessions: yours, shared ones, and your end users'. Answers 404 while devices are off for the service. */
|
|
183
|
+
class Devices {
|
|
184
|
+
c;
|
|
185
|
+
constructor(c) {
|
|
186
|
+
this.c = c;
|
|
187
|
+
}
|
|
188
|
+
/** `scope` is `user`, `org` or `end_user`; `end_user_id` narrows to one end user's devices. Capped at 200, no cursor. */
|
|
189
|
+
list(params = {}) { return this.c.request("GET", "/v1/devices", { params }); }
|
|
190
|
+
get(deviceId) { return this.c.request("GET", `/v1/devices/${seg(deviceId)}`); }
|
|
191
|
+
}
|
|
192
|
+
/** Hosted agent runs. These use your organization's own model key. */
|
|
193
|
+
class Runs {
|
|
194
|
+
c;
|
|
195
|
+
constructor(c) {
|
|
196
|
+
this.c = c;
|
|
197
|
+
}
|
|
198
|
+
/** `fields`: `profile_id`, `agent_id`, `model`, `max_steps`, `max_cost_usd`, `max_duration_s`, `result_schema`, `variables`, `project_id`. */
|
|
199
|
+
create(task, fields = {}, options = {}) {
|
|
200
|
+
return this.c.request("POST", "/v1/agents/runs", { json: { task, ...clean(fields) }, headers: { "Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID() } });
|
|
201
|
+
}
|
|
202
|
+
get(runId) { return this.c.request("GET", `/v1/agents/runs/${seg(runId)}`); }
|
|
203
|
+
list(params = {}) { return this.c.request("GET", "/v1/agents/runs", { params }); }
|
|
204
|
+
messages(runId) { return this.c.request("GET", `/v1/agents/runs/${seg(runId)}/messages`); }
|
|
205
|
+
cancel(runId) { return this.c.request("POST", `/v1/agents/runs/${seg(runId)}/cancel`); }
|
|
206
|
+
/** Poll until the run finishes. Returns the run whatever its outcome, so check `status`. */
|
|
207
|
+
async wait(runId, options = {}) {
|
|
208
|
+
const timeoutS = options.timeoutS ?? 600;
|
|
209
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
210
|
+
for (;;) {
|
|
211
|
+
const run = await this.get(runId);
|
|
212
|
+
if (run.status === "completed" || run.status === "failed" || run.status === "cancelled")
|
|
213
|
+
return run;
|
|
214
|
+
if (Date.now() >= deadline)
|
|
215
|
+
throw new SteerpaneError(`Run ${runId} had not finished after ${timeoutS} s (status ${run.status})`);
|
|
216
|
+
await sleep((options.pollS ?? 2) * 1000);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
class Files {
|
|
221
|
+
c;
|
|
222
|
+
constructor(c) {
|
|
223
|
+
this.c = c;
|
|
224
|
+
}
|
|
225
|
+
/** Store a file: a path, or bytes with `name`. The result's `id` goes straight into `actions.uploadFile(sessionId, { file_id })`. */
|
|
226
|
+
async upload(file, options = {}) {
|
|
227
|
+
const bytes = typeof file === "string" ? await readFile(file) : file;
|
|
228
|
+
const filename = options.name ?? (typeof file === "string" ? basename(file) : "file");
|
|
229
|
+
const form = new FormData();
|
|
230
|
+
form.append("file", new Blob([bytes], { type: options.mime ?? "application/octet-stream" }), filename);
|
|
231
|
+
if (options.name)
|
|
232
|
+
form.append("name", options.name);
|
|
233
|
+
if (options.ttlHours !== undefined)
|
|
234
|
+
form.append("ttl_hours", String(options.ttlHours));
|
|
235
|
+
return this.c.request("POST", "/v1/files", { body: form });
|
|
236
|
+
}
|
|
237
|
+
createUploadUrl(name, fields = {}) { return this.c.request("POST", "/v1/files/uploads", { json: { name, ...clean(fields) } }); }
|
|
238
|
+
list(params = {}) { return this.c.request("GET", "/v1/files", { params }); }
|
|
239
|
+
get(fileId) { return this.c.request("GET", `/v1/files/${seg(fileId)}`); }
|
|
240
|
+
/** The file's bytes. On S3 storage the API redirects to a presigned URL, which `fetch` follows without your token. */
|
|
241
|
+
async download(fileId) {
|
|
242
|
+
const res = await this.c.rawRequest("GET", `/v1/files/${seg(fileId)}/content`);
|
|
243
|
+
if (!res.ok)
|
|
244
|
+
throw errorFromResponse(res.status, await parse(res), retryAfterOf(res));
|
|
245
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
246
|
+
}
|
|
247
|
+
async delete(fileId) { await this.c.request("DELETE", `/v1/files/${seg(fileId)}`); }
|
|
248
|
+
}
|
|
249
|
+
/** Links that show a session inside your own product. Scale and Enterprise plans. The URL is a credential: keep it out of logs. */
|
|
250
|
+
class Embeds {
|
|
251
|
+
c;
|
|
252
|
+
constructor(c) {
|
|
253
|
+
this.c = c;
|
|
254
|
+
}
|
|
255
|
+
create(sessionId, config = {}) {
|
|
256
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/embed`, { json: { expires_in_min: 30, ...clean(config) } });
|
|
257
|
+
}
|
|
258
|
+
list(params = {}) { return this.c.request("GET", "/v1/embed-links", { params }); }
|
|
259
|
+
/** Closes open iframes at once, and returns control to the agent if this link held it. */
|
|
260
|
+
revoke(linkId) { return this.c.request("POST", `/v1/embed-links/${seg(linkId)}/revoke`); }
|
|
261
|
+
}
|
|
262
|
+
/** Endpoint management. To check a delivery's signature use `verifySignature`, which needs no client. */
|
|
263
|
+
class Webhooks {
|
|
264
|
+
c;
|
|
265
|
+
constructor(c) {
|
|
266
|
+
this.c = c;
|
|
267
|
+
}
|
|
268
|
+
list() { return this.c.request("GET", "/v1/webhooks"); }
|
|
269
|
+
create(url, events, fields = {}) { return this.c.request("POST", "/v1/webhooks", { json: { url, events, ...clean(fields) } }); }
|
|
270
|
+
get(webhookId) { return this.c.request("GET", `/v1/webhooks/${seg(webhookId)}`); }
|
|
271
|
+
async delete(webhookId) { await this.c.request("DELETE", `/v1/webhooks/${seg(webhookId)}`); }
|
|
272
|
+
sendTest(webhookId, input = {}) { return this.c.request("POST", `/v1/webhooks/${seg(webhookId)}/test`, { json: clean(input) }); }
|
|
273
|
+
}
|
|
274
|
+
/** Stored logins. Passwords and TOTP secrets go in and never come back to an API token. */
|
|
275
|
+
class Vault {
|
|
276
|
+
c;
|
|
277
|
+
constructor(c) {
|
|
278
|
+
this.c = c;
|
|
279
|
+
}
|
|
280
|
+
list() { return this.c.request("GET", "/v1/vault/credentials"); }
|
|
281
|
+
create(input) { return this.c.request("POST", "/v1/vault/credentials", { json: clean(input) }); }
|
|
282
|
+
async delete(credentialId) { await this.c.request("DELETE", `/v1/vault/credentials/${seg(credentialId)}`); }
|
|
283
|
+
}
|
|
284
|
+
class Crawls {
|
|
285
|
+
c;
|
|
286
|
+
constructor(c) {
|
|
287
|
+
this.c = c;
|
|
288
|
+
}
|
|
289
|
+
/** `options`: `limit`, `max_depth`, `mode`, `formats`, `include_paths`, `exclude_paths`, `check_links`, `same_site`, ... */
|
|
290
|
+
start(url, options = {}) { return this.c.request("POST", "/v1/crawl", { json: { url, ...clean(options) } }); }
|
|
291
|
+
get(jobId, params = {}) { return this.c.request("GET", `/v1/crawl/${seg(jobId)}`, { params }); }
|
|
292
|
+
cancel(jobId) { return this.c.request("DELETE", `/v1/crawl/${seg(jobId)}`); }
|
|
293
|
+
async wait(jobId, options = {}) {
|
|
294
|
+
const timeoutS = options.timeoutS ?? 300;
|
|
295
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
296
|
+
for (;;) {
|
|
297
|
+
const job = await this.get(jobId, { include_content: false });
|
|
298
|
+
if (job.status === "completed" || job.status === "failed" || job.status === "cancelled")
|
|
299
|
+
return job;
|
|
300
|
+
if (Date.now() >= deadline)
|
|
301
|
+
throw new SteerpaneError(`Crawl ${jobId} had not finished after ${timeoutS} s`);
|
|
302
|
+
await sleep((options.pollS ?? 2) * 1000);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** Call MCP tools over the API's Streamable HTTP endpoint. A few tools are MCP only, such as `act`, `observe` and `wait_for_code`. */
|
|
307
|
+
class Mcp {
|
|
308
|
+
c;
|
|
309
|
+
sessionId = null;
|
|
310
|
+
nextId = 0;
|
|
311
|
+
constructor(c) {
|
|
312
|
+
this.c = c;
|
|
313
|
+
}
|
|
314
|
+
async post(method, params, options = {}) {
|
|
315
|
+
const id = options.notify ? undefined : ++this.nextId;
|
|
316
|
+
const headers = { Accept: "application/json, text/event-stream", "Mcp-Protocol-Version": MCP_PROTOCOL };
|
|
317
|
+
if (this.sessionId)
|
|
318
|
+
headers["Mcp-Session-Id"] = this.sessionId;
|
|
319
|
+
const res = await this.c.rawRequest("POST", "/mcp", { json: { jsonrpc: "2.0", method, params, ...(id ? { id } : {}) }, headers, timeoutMs: options.timeoutMs });
|
|
320
|
+
if (res.status >= 400)
|
|
321
|
+
throw errorFromResponse(res.status, await parse(res), retryAfterOf(res));
|
|
322
|
+
this.sessionId = res.headers.get("mcp-session-id") ?? this.sessionId;
|
|
323
|
+
if (options.notify)
|
|
324
|
+
return null;
|
|
325
|
+
const text = await res.text();
|
|
326
|
+
let reply;
|
|
327
|
+
if ((res.headers.get("content-type") ?? "").includes("event-stream")) {
|
|
328
|
+
const frames = text.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).filter(Boolean);
|
|
329
|
+
reply = frames.map((f) => JSON.parse(f)).find((m) => m.id === id);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
reply = JSON.parse(text);
|
|
333
|
+
}
|
|
334
|
+
if (!reply)
|
|
335
|
+
throw new SteerpaneError(`MCP ${method}: the server sent no reply`);
|
|
336
|
+
if (reply.error)
|
|
337
|
+
throw new SteerpaneError(`MCP ${method}: ${reply.error.message}`);
|
|
338
|
+
return reply.result ?? null;
|
|
339
|
+
}
|
|
340
|
+
async ensureSession() {
|
|
341
|
+
if (this.sessionId)
|
|
342
|
+
return;
|
|
343
|
+
await this.post("initialize", { protocolVersion: MCP_PROTOCOL, capabilities: {}, clientInfo: { name: "steerpane-sdk-node", version: VERSION } });
|
|
344
|
+
await this.post("notifications/initialized", {}, { notify: true });
|
|
345
|
+
}
|
|
346
|
+
/** The default list: core tools plus `search_tools` and `call_tool`. Any tool still runs by name with `call`. */
|
|
347
|
+
async listTools() {
|
|
348
|
+
await this.ensureSession();
|
|
349
|
+
return (await this.post("tools/list", {}))?.tools ?? [];
|
|
350
|
+
}
|
|
351
|
+
/** Run a tool and return its JSON result. A tool failure throws `ToolError` with the API's error code. */
|
|
352
|
+
async call(tool, args = {}, options = {}) {
|
|
353
|
+
await this.ensureSession();
|
|
354
|
+
const result = await this.post("tools/call", { name: tool, arguments: clean(args) }, { timeoutMs: (options.timeoutS ?? 120) * 1000 });
|
|
355
|
+
const text = result?.content?.find((c) => c.type === "text")?.text;
|
|
356
|
+
let payload = result?.structuredContent;
|
|
357
|
+
if (payload === undefined && text) {
|
|
358
|
+
try {
|
|
359
|
+
payload = JSON.parse(text);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
payload = text;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (result?.isError) {
|
|
366
|
+
const err = payload?.error;
|
|
367
|
+
throw new ToolError(tool, err?.code ?? "tool_error", err?.message ?? text ?? "Tool failed", err?.details ?? {});
|
|
368
|
+
}
|
|
369
|
+
return payload;
|
|
370
|
+
}
|
|
371
|
+
/** Wait for an email or SMS verification code (the MCP tool `wait_for_code`). With `sessionId` the code is typed into the page and never reaches you
|
|
372
|
+
* (`status: "entered"`); with `returnCode: true` you get it back (`status: "received"`). The server answers in rounds of about 45 s with `status: "waiting"`:
|
|
373
|
+
* this keeps calling, with the `started_at` it was given, until the code arrives or `timeoutS` runs out (`status: "timeout"`), so the result is always final.
|
|
374
|
+
* Needs the `vault:use` scope and a code source set up on the organization. */
|
|
375
|
+
async waitForCode(options = {}) {
|
|
376
|
+
const args = {
|
|
377
|
+
session_id: options.sessionId, profile_id: options.profileId, credential_id: options.credentialId, site: options.site, sender: options.sender, subject: options.subject,
|
|
378
|
+
timeout_s: options.timeoutS, max_age_s: options.maxAgeS, selector: options.selector, frame: options.frame, submit: options.submit, return_code: options.returnCode, code_id: options.codeId,
|
|
379
|
+
};
|
|
380
|
+
const timeoutS = options.timeoutS ?? 120;
|
|
381
|
+
for (let round = 0;; round++) {
|
|
382
|
+
const result = await this.call("wait_for_code", args, { timeoutS: 90 });
|
|
383
|
+
if (result.status !== "waiting")
|
|
384
|
+
return result;
|
|
385
|
+
if (round > timeoutS)
|
|
386
|
+
throw new SteerpaneError("wait_for_code kept answering 'waiting' past its own timeout");
|
|
387
|
+
args["started_at"] = result["started_at"];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** Client for the Steerpane API. */
|
|
392
|
+
export class Steerpane {
|
|
393
|
+
baseUrl;
|
|
394
|
+
timeoutMs;
|
|
395
|
+
maxRetries;
|
|
396
|
+
/** Sent as X-Steerpane-Agent: the name the dashboard shows for this program. */
|
|
397
|
+
agentName;
|
|
398
|
+
fetchImpl;
|
|
399
|
+
sessions = new Sessions(this);
|
|
400
|
+
actions = new Actions(this);
|
|
401
|
+
profiles = new Profiles(this);
|
|
402
|
+
endUsers = new EndUsers(this);
|
|
403
|
+
devices = new Devices(this);
|
|
404
|
+
runs = new Runs(this);
|
|
405
|
+
files = new Files(this);
|
|
406
|
+
embeds = new Embeds(this);
|
|
407
|
+
webhooks = new Webhooks(this);
|
|
408
|
+
vault = new Vault(this);
|
|
409
|
+
crawls = new Crawls(this);
|
|
410
|
+
mcp = new Mcp(this);
|
|
411
|
+
constructor(options = {}) {
|
|
412
|
+
const token = options.token ?? process.env["STEERPANE_API_TOKEN"];
|
|
413
|
+
if (!token)
|
|
414
|
+
throw new SteerpaneError("No API token. Pass token or set STEERPANE_API_TOKEN.");
|
|
415
|
+
Object.defineProperty(this, "token", { value: token, enumerable: false });
|
|
416
|
+
this.baseUrl = (options.baseUrl ?? process.env["STEERPANE_API_URL"] ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
417
|
+
let url;
|
|
418
|
+
try {
|
|
419
|
+
url = new URL(this.baseUrl);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
throw new SteerpaneError(`Invalid API URL: ${this.baseUrl}`);
|
|
423
|
+
}
|
|
424
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
425
|
+
throw new SteerpaneError(`Invalid API URL: ${this.baseUrl}`);
|
|
426
|
+
if (url.protocol === "http:" && !LOCAL_HOSTS.has(url.hostname) && !options.allowInsecureHttp) {
|
|
427
|
+
throw new SteerpaneError("Refusing to send an API token over plain http. Use https, or pass allowInsecureHttp: true.");
|
|
428
|
+
}
|
|
429
|
+
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
430
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
431
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
432
|
+
this.agentName = (options.agentName ?? process.env["STEERPANE_AGENT_NAME"] ?? "").trim().slice(0, 80) || undefined;
|
|
433
|
+
}
|
|
434
|
+
/** One HTTP call with your token and the retry rules. Doesn't throw on an error status. */
|
|
435
|
+
async rawRequest(method, path, options = {}) {
|
|
436
|
+
const query = new URLSearchParams();
|
|
437
|
+
for (const [k, v] of Object.entries(options.params ?? {}))
|
|
438
|
+
if (v !== undefined && v !== null)
|
|
439
|
+
query.set(k, String(v));
|
|
440
|
+
const url = this.baseUrl + path + (query.size ? `?${query}` : "");
|
|
441
|
+
const headers = {
|
|
442
|
+
Authorization: `Bearer ${this.token}`, "User-Agent": `steerpane-sdk-node/${VERSION}`,
|
|
443
|
+
...(this.agentName ? { "X-Steerpane-Agent": this.agentName } : {}), ...options.headers,
|
|
444
|
+
};
|
|
445
|
+
if (options.json !== undefined)
|
|
446
|
+
headers["Content-Type"] = "application/json";
|
|
447
|
+
const body = options.body ?? (options.json !== undefined ? JSON.stringify(options.json) : undefined);
|
|
448
|
+
const keyed = "Idempotency-Key" in headers;
|
|
449
|
+
const noRetry = path.split("?")[0].replace(/\/+$/, "").endsWith("/secret-fill"); // typing a secret twice can't be undone: never retried, whatever the error
|
|
450
|
+
const idempotent = method === "GET" || method === "HEAD" || method === "DELETE" || keyed;
|
|
451
|
+
for (let attempt = 0;; attempt++) {
|
|
452
|
+
let delay;
|
|
453
|
+
try {
|
|
454
|
+
const res = await this.fetchImpl(url, { method, headers, body, signal: AbortSignal.timeout(options.timeoutMs ?? this.timeoutMs) });
|
|
455
|
+
// A stored 429 replays under an Idempotency-Key, so a keyed POST isn't retried on it. A rate-limited request never ran.
|
|
456
|
+
const rateLimited = !noRetry && res.status === 429 && !keyed && (await isRateLimited(res));
|
|
457
|
+
if (!((!noRetry && idempotent && RETRY_STATUSES.has(res.status)) || rateLimited) || attempt >= this.maxRetries)
|
|
458
|
+
return res;
|
|
459
|
+
delay = retryAfterOf(res) ?? 0.5 * 2 ** attempt;
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
const timedOut = e instanceof Error && e.name === "TimeoutError";
|
|
463
|
+
if (noRetry || !idempotent || timedOut || attempt >= this.maxRetries)
|
|
464
|
+
throw e;
|
|
465
|
+
delay = 0.5 * 2 ** attempt;
|
|
466
|
+
}
|
|
467
|
+
await sleep(Math.min(delay, 30) * 1000);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** Call any endpoint, for what this client has no method for. Returns the parsed body, or throws `APIError`. */
|
|
471
|
+
async request(method, path, options = {}) {
|
|
472
|
+
const res = await this.rawRequest(method, path, options);
|
|
473
|
+
const parsed = await parse(res);
|
|
474
|
+
if (res.status >= 300)
|
|
475
|
+
throw errorFromResponse(res.status, parsed, retryAfterOf(res));
|
|
476
|
+
return parsed;
|
|
477
|
+
}
|
|
478
|
+
/** Every item across pages: `for await (const s of client.paginate((p) => client.sessions.list({ ...p, status: "live" })))`. */
|
|
479
|
+
async *paginate(listCall) {
|
|
480
|
+
let cursor = null;
|
|
481
|
+
do {
|
|
482
|
+
const page = await listCall({ cursor });
|
|
483
|
+
yield* page.data;
|
|
484
|
+
cursor = page.next_cursor ?? null;
|
|
485
|
+
} while (cursor);
|
|
486
|
+
}
|
|
487
|
+
/** Fetch a page without a browser session. `mode` is `fast`, `deep` or `auto`. The site's own status is in the result's `status`. */
|
|
488
|
+
fetch(url, options = {}) { return this.request("POST", "/v1/fetch", { json: { url, ...clean(options) } }); }
|
|
489
|
+
fetchBatch(urls, options = {}) { return this.request("POST", "/v1/fetch/batch", { json: { urls, ...clean(options) }, timeoutMs: Math.max(this.timeoutMs, 180_000) }); }
|
|
490
|
+
search(query, options = {}) { return this.request("POST", "/v1/search", { json: { query, ...clean(options) } }); }
|
|
491
|
+
screenshot(url, options = {}) { return this.request("POST", "/v1/screenshot", { json: { url, ...clean(options) }, timeoutMs: Math.max(this.timeoutMs, 120_000) }); }
|
|
492
|
+
pdf(url, options = {}) { return this.request("POST", "/v1/pdf", { json: { url, ...clean(options) }, timeoutMs: Math.max(this.timeoutMs, 120_000) }); }
|
|
493
|
+
map(url, options = {}) { return this.request("POST", "/v1/map", { json: { url, ...clean(options) }, timeoutMs: Math.max(this.timeoutMs, 120_000) }); }
|
|
494
|
+
/** Structured data from up to 10 URLs. Needs a model key on the organization. */
|
|
495
|
+
extract(input) { return this.request("POST", "/v1/extract", { json: clean(input), timeoutMs: Math.max(this.timeoutMs, 180_000) }); }
|
|
496
|
+
}
|
|
497
|
+
async function isRateLimited(res) {
|
|
498
|
+
try {
|
|
499
|
+
const body = (await res.clone().json());
|
|
500
|
+
return body.error?.code === "rate_limited";
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
export { APIError };
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Everything this package raises on purpose. */
|
|
2
|
+
export declare class SteerpaneError extends Error {
|
|
3
|
+
name: string;
|
|
4
|
+
}
|
|
5
|
+
/** The API answered with an error. `code` is the stable value to branch on; `message` is written for people. */
|
|
6
|
+
export declare class APIError extends SteerpaneError {
|
|
7
|
+
readonly status: number;
|
|
8
|
+
readonly code: string;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly details: Record<string, unknown>;
|
|
11
|
+
readonly retryAfter: number | null;
|
|
12
|
+
name: string;
|
|
13
|
+
constructor(status: number, code: string, message: string, details?: Record<string, unknown>, retryAfter?: number | null);
|
|
14
|
+
}
|
|
15
|
+
/** 409 `human_in_control`. The command was queued; wait for the human with `sessions.waitForHuman`. */
|
|
16
|
+
export declare class HumanInControlError extends APIError {
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
/** An MCP tool answered with `isError`. `code` and `details` come from the same envelope as REST errors. */
|
|
20
|
+
export declare class ToolError extends SteerpaneError {
|
|
21
|
+
readonly tool: string;
|
|
22
|
+
readonly code: string;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
readonly details: Record<string, unknown>;
|
|
25
|
+
name: string;
|
|
26
|
+
constructor(tool: string, code: string, message: string, details?: Record<string, unknown>);
|
|
27
|
+
}
|
|
28
|
+
export declare function errorFromResponse(status: number, body: unknown, retryAfter: number | null): APIError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Everything this package raises on purpose. */
|
|
2
|
+
export class SteerpaneError extends Error {
|
|
3
|
+
name = "SteerpaneError";
|
|
4
|
+
}
|
|
5
|
+
/** The API answered with an error. `code` is the stable value to branch on; `message` is written for people. */
|
|
6
|
+
export class APIError extends SteerpaneError {
|
|
7
|
+
status;
|
|
8
|
+
code;
|
|
9
|
+
message;
|
|
10
|
+
details;
|
|
11
|
+
retryAfter;
|
|
12
|
+
name = "APIError";
|
|
13
|
+
constructor(status, code, message, details = {}, retryAfter = null) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.message = message;
|
|
18
|
+
this.details = details;
|
|
19
|
+
this.retryAfter = retryAfter;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** 409 `human_in_control`. The command was queued; wait for the human with `sessions.waitForHuman`. */
|
|
23
|
+
export class HumanInControlError extends APIError {
|
|
24
|
+
name = "HumanInControlError";
|
|
25
|
+
}
|
|
26
|
+
/** An MCP tool answered with `isError`. `code` and `details` come from the same envelope as REST errors. */
|
|
27
|
+
export class ToolError extends SteerpaneError {
|
|
28
|
+
tool;
|
|
29
|
+
code;
|
|
30
|
+
message;
|
|
31
|
+
details;
|
|
32
|
+
name = "ToolError";
|
|
33
|
+
constructor(tool, code, message, details = {}) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.tool = tool;
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.message = message;
|
|
38
|
+
this.details = details;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function errorFromResponse(status, body, retryAfter) {
|
|
42
|
+
const envelope = typeof body === "object" && body !== null ? body.error : undefined;
|
|
43
|
+
const err = (typeof envelope === "object" && envelope !== null ? envelope : {});
|
|
44
|
+
const code = typeof err.code === "string" && err.code ? err.code : `http_${status}`;
|
|
45
|
+
const message = String(err.message || (typeof body === "string" && body ? body : `HTTP ${status}`)).slice(0, 2000);
|
|
46
|
+
const details = (err.details ?? {});
|
|
47
|
+
const Cls = code === "human_in_control" ? HumanInControlError : APIError;
|
|
48
|
+
return new Cls(status, code, message, details, retryAfter);
|
|
49
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { DEFAULT_BASE_URL, Steerpane, type SteerpaneOptions } from "./client.js";
|
|
2
|
+
export { APIError, HumanInControlError, SteerpaneError, ToolError } from "./errors.js";
|
|
3
|
+
export { connectPlaywright, type ConnectOptions } from "./playwright.js";
|
|
4
|
+
export type * from "./types.js";
|
|
5
|
+
export { VERSION } from "./version.js";
|
|
6
|
+
export { constructEvent, DELIVERY_HEADER, SIGNATURE_HEADER, verifySignature, WebhookSignatureError, type SignatureSource, type VerifyOptions } from "./webhooks.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { DEFAULT_BASE_URL, Steerpane } from "./client.js";
|
|
2
|
+
export { APIError, HumanInControlError, SteerpaneError, ToolError } from "./errors.js";
|
|
3
|
+
export { connectPlaywright } from "./playwright.js";
|
|
4
|
+
export { VERSION } from "./version.js";
|
|
5
|
+
export { constructEvent, DELIVERY_HEADER, SIGNATURE_HEADER, verifySignature, WebhookSignatureError } from "./webhooks.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect Playwright to a Steerpane session over CDP. Needs `playwright` or `playwright-core` installed alongside this package.
|
|
3
|
+
*
|
|
4
|
+
* The token travels in a header, never in the URL, so it stays out of proxy and server logs. Closing the connection leaves the session
|
|
5
|
+
* running: stop it with `client.sessions.stop(id)` when you are finished.
|
|
6
|
+
*/
|
|
7
|
+
import type { Browser } from "playwright-core";
|
|
8
|
+
import type { Steerpane } from "./client.js";
|
|
9
|
+
import type { Session } from "./types.js";
|
|
10
|
+
export interface ConnectOptions {
|
|
11
|
+
/** Shown as the agent's name in the dashboard. */
|
|
12
|
+
agent?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Waits for the browser to be ready and connects. The open tab is `browser.contexts()[0].pages()[0]`. */
|
|
16
|
+
export declare function connectPlaywright(client: Steerpane, session: string | Pick<Session, "id">, options?: ConnectOptions): Promise<Browser>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { SteerpaneError } from "./errors.js";
|
|
2
|
+
async function loadChromium() {
|
|
3
|
+
for (const name of ["playwright-core", "playwright"]) {
|
|
4
|
+
try {
|
|
5
|
+
return (await import(name)).chromium;
|
|
6
|
+
}
|
|
7
|
+
catch (e) {
|
|
8
|
+
if (e.code !== "ERR_MODULE_NOT_FOUND")
|
|
9
|
+
throw e;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
throw new SteerpaneError("Playwright is not installed. Run: npm install playwright-core");
|
|
13
|
+
}
|
|
14
|
+
/** Waits for the browser to be ready and connects. The open tab is `browser.contexts()[0].pages()[0]`. */
|
|
15
|
+
export async function connectPlaywright(client, session, options = {}) {
|
|
16
|
+
const chromium = await loadChromium();
|
|
17
|
+
const ready = await client.sessions.waitUntilReady(typeof session === "string" ? session : session.id);
|
|
18
|
+
const url = options.agent ? `${ready.cdp_url}?agent=${encodeURIComponent(options.agent)}` : ready.cdp_url;
|
|
19
|
+
return chromium.connectOverCDP(url, { headers: { Authorization: `Bearer ${client.token}` }, timeout: options.timeoutMs ?? 30_000 });
|
|
20
|
+
}
|