@steerpane/sdk 0.0.0-stage → 0.1.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 +106 -3
- package/dist/client.d.ts +312 -0
- package/dist/client.js +411 -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 +162 -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 +31 -6
package/dist/client.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
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 stored credential into the page. The secret never comes back. Refused off the credential's site. */
|
|
55
|
+
vaultFill(sessionId, credentialId, args = {}) { return this.run(sessionId, "vault_fill", { credential_id: credentialId, ...args }); }
|
|
56
|
+
}
|
|
57
|
+
class Sessions {
|
|
58
|
+
c;
|
|
59
|
+
constructor(c) {
|
|
60
|
+
this.c = c;
|
|
61
|
+
}
|
|
62
|
+
list(params = {}) {
|
|
63
|
+
return this.c.request("GET", "/v1/sessions", { params });
|
|
64
|
+
}
|
|
65
|
+
/** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `auto_close`, `timeout_min`. */
|
|
66
|
+
create(config = {}, options = {}) {
|
|
67
|
+
return this.c.request("POST", "/v1/sessions", { json: clean(config), headers: { "Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID() } });
|
|
68
|
+
}
|
|
69
|
+
get(sessionId) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}`); }
|
|
70
|
+
/** Wait for the browser to leave `starting`. Throws if it ends instead, or doesn't start in time. */
|
|
71
|
+
async waitUntilReady(sessionId, options = {}) {
|
|
72
|
+
const timeoutS = options.timeoutS ?? 90;
|
|
73
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const session = await this.get(sessionId);
|
|
76
|
+
if (session.status === "stopped" || session.status === "error")
|
|
77
|
+
throw new SteerpaneError(`Session ${sessionId} ended before it was ready: ${session.error_message ?? session.status}`);
|
|
78
|
+
if (session.status !== "starting")
|
|
79
|
+
return session;
|
|
80
|
+
if (Date.now() >= deadline)
|
|
81
|
+
throw new SteerpaneError(`Session ${sessionId} was still starting after ${timeoutS} s`);
|
|
82
|
+
await sleep((options.pollS ?? 0.5) * 1000);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
stop(sessionId) { return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/stop`); }
|
|
86
|
+
async delete(sessionId) { await this.c.request("DELETE", `/v1/sessions/${seg(sessionId)}`); }
|
|
87
|
+
keepalive(sessionId) { return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/keepalive`); }
|
|
88
|
+
takeover(sessionId) { return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/takeover`); }
|
|
89
|
+
/** Give control back. The waiting agent receives `outcome` and `note`. */
|
|
90
|
+
handback(sessionId, input = {}) {
|
|
91
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/handback`, { json: clean(input) });
|
|
92
|
+
}
|
|
93
|
+
/** Ask a person for help. The agent goes on hold, and `agent.human_help_requested` fires. */
|
|
94
|
+
requestHelp(sessionId, input) {
|
|
95
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/help-requests`, { json: { message: "", ...input } });
|
|
96
|
+
}
|
|
97
|
+
/** Long-poll until the human hands back. `status` is `handed_back`, `timeout` (call again) or `stopped`. */
|
|
98
|
+
waitForHuman(sessionId, options = {}) {
|
|
99
|
+
const timeoutS = options.timeoutS ?? 30;
|
|
100
|
+
return this.c.request("GET", `/v1/sessions/${seg(sessionId)}/help-requests/${seg(options.requestId ?? "latest")}/wait`, {
|
|
101
|
+
params: { timeout_s: timeoutS }, timeoutMs: Math.max(this.c.timeoutMs, (timeoutS + 30) * 1000),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
events(sessionId, params = {}) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}/events`, { params }); }
|
|
105
|
+
files(sessionId) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}/files`); }
|
|
106
|
+
/** Pack what the session downloaded into one ZIP in the file store. */
|
|
107
|
+
zipFiles(sessionId) { return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/files/zip`); }
|
|
108
|
+
}
|
|
109
|
+
class Profiles {
|
|
110
|
+
c;
|
|
111
|
+
constructor(c) {
|
|
112
|
+
this.c = c;
|
|
113
|
+
}
|
|
114
|
+
list(params = {}) { return this.c.request("GET", "/v1/profiles", { params }); }
|
|
115
|
+
get(profileId) { return this.c.request("GET", `/v1/profiles/${seg(profileId)}`); }
|
|
116
|
+
create(name, fields = {}) { return this.c.request("POST", "/v1/profiles", { json: { name, ...clean(fields) } }); }
|
|
117
|
+
update(profileId, fields) { return this.c.request("PATCH", `/v1/profiles/${seg(profileId)}`, { json: fields }); }
|
|
118
|
+
async delete(profileId) { await this.c.request("DELETE", `/v1/profiles/${seg(profileId)}`); }
|
|
119
|
+
clone(profileId) { return this.c.request("POST", `/v1/profiles/${seg(profileId)}/clone`); }
|
|
120
|
+
/** Really load each signed-in site with the profile's state, and report whether it is still signed in. */
|
|
121
|
+
verify(profileId, input = {}) { return this.c.request("POST", `/v1/profiles/${seg(profileId)}/verify`, { json: clean(input), timeoutMs: Math.max(this.c.timeoutMs, 90_000) }); }
|
|
122
|
+
lock(profileId) { return this.c.request("GET", `/v1/profiles/${seg(profileId)}/lock`); }
|
|
123
|
+
importCookies(profileId, cookies) { return this.c.request("POST", `/v1/profiles/${seg(profileId)}/cookies/import`, { json: { cookies } }); }
|
|
124
|
+
}
|
|
125
|
+
/** Hosted agent runs. These use your organization's own model key. */
|
|
126
|
+
class Runs {
|
|
127
|
+
c;
|
|
128
|
+
constructor(c) {
|
|
129
|
+
this.c = c;
|
|
130
|
+
}
|
|
131
|
+
/** `fields`: `profile_id`, `agent_id`, `model`, `max_steps`, `max_cost_usd`, `max_duration_s`, `result_schema`, `variables`, `project_id`. */
|
|
132
|
+
create(task, fields = {}, options = {}) {
|
|
133
|
+
return this.c.request("POST", "/v1/agents/runs", { json: { task, ...clean(fields) }, headers: { "Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID() } });
|
|
134
|
+
}
|
|
135
|
+
get(runId) { return this.c.request("GET", `/v1/agents/runs/${seg(runId)}`); }
|
|
136
|
+
list(params = {}) { return this.c.request("GET", "/v1/agents/runs", { params }); }
|
|
137
|
+
messages(runId) { return this.c.request("GET", `/v1/agents/runs/${seg(runId)}/messages`); }
|
|
138
|
+
cancel(runId) { return this.c.request("POST", `/v1/agents/runs/${seg(runId)}/cancel`); }
|
|
139
|
+
/** Poll until the run finishes. Returns the run whatever its outcome, so check `status`. */
|
|
140
|
+
async wait(runId, options = {}) {
|
|
141
|
+
const timeoutS = options.timeoutS ?? 600;
|
|
142
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
143
|
+
for (;;) {
|
|
144
|
+
const run = await this.get(runId);
|
|
145
|
+
if (run.status === "completed" || run.status === "failed" || run.status === "cancelled")
|
|
146
|
+
return run;
|
|
147
|
+
if (Date.now() >= deadline)
|
|
148
|
+
throw new SteerpaneError(`Run ${runId} had not finished after ${timeoutS} s (status ${run.status})`);
|
|
149
|
+
await sleep((options.pollS ?? 2) * 1000);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
class Files {
|
|
154
|
+
c;
|
|
155
|
+
constructor(c) {
|
|
156
|
+
this.c = c;
|
|
157
|
+
}
|
|
158
|
+
/** Store a file: a path, or bytes with `name`. The result's `id` goes straight into `actions.uploadFile(sessionId, { file_id })`. */
|
|
159
|
+
async upload(file, options = {}) {
|
|
160
|
+
const bytes = typeof file === "string" ? await readFile(file) : file;
|
|
161
|
+
const filename = options.name ?? (typeof file === "string" ? basename(file) : "file");
|
|
162
|
+
const form = new FormData();
|
|
163
|
+
form.append("file", new Blob([bytes], { type: options.mime ?? "application/octet-stream" }), filename);
|
|
164
|
+
if (options.name)
|
|
165
|
+
form.append("name", options.name);
|
|
166
|
+
if (options.ttlHours !== undefined)
|
|
167
|
+
form.append("ttl_hours", String(options.ttlHours));
|
|
168
|
+
return this.c.request("POST", "/v1/files", { body: form });
|
|
169
|
+
}
|
|
170
|
+
createUploadUrl(name, fields = {}) { return this.c.request("POST", "/v1/files/uploads", { json: { name, ...clean(fields) } }); }
|
|
171
|
+
list(params = {}) { return this.c.request("GET", "/v1/files", { params }); }
|
|
172
|
+
get(fileId) { return this.c.request("GET", `/v1/files/${seg(fileId)}`); }
|
|
173
|
+
/** The file's bytes. On S3 storage the API redirects to a presigned URL, which `fetch` follows without your token. */
|
|
174
|
+
async download(fileId) {
|
|
175
|
+
const res = await this.c.rawRequest("GET", `/v1/files/${seg(fileId)}/content`);
|
|
176
|
+
if (!res.ok)
|
|
177
|
+
throw errorFromResponse(res.status, await parse(res), retryAfterOf(res));
|
|
178
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
179
|
+
}
|
|
180
|
+
async delete(fileId) { await this.c.request("DELETE", `/v1/files/${seg(fileId)}`); }
|
|
181
|
+
}
|
|
182
|
+
/** Links that show a session inside your own product. Scale and Enterprise plans. The URL is a credential: keep it out of logs. */
|
|
183
|
+
class Embeds {
|
|
184
|
+
c;
|
|
185
|
+
constructor(c) {
|
|
186
|
+
this.c = c;
|
|
187
|
+
}
|
|
188
|
+
create(sessionId, config = {}) {
|
|
189
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/embed`, { json: { expires_in_min: 30, ...clean(config) } });
|
|
190
|
+
}
|
|
191
|
+
list(params = {}) { return this.c.request("GET", "/v1/embed-links", { params }); }
|
|
192
|
+
/** Closes open iframes at once, and returns control to the agent if this link held it. */
|
|
193
|
+
revoke(linkId) { return this.c.request("POST", `/v1/embed-links/${seg(linkId)}/revoke`); }
|
|
194
|
+
}
|
|
195
|
+
/** Endpoint management. To check a delivery's signature use `verifySignature`, which needs no client. */
|
|
196
|
+
class Webhooks {
|
|
197
|
+
c;
|
|
198
|
+
constructor(c) {
|
|
199
|
+
this.c = c;
|
|
200
|
+
}
|
|
201
|
+
list() { return this.c.request("GET", "/v1/webhooks"); }
|
|
202
|
+
create(url, events, fields = {}) { return this.c.request("POST", "/v1/webhooks", { json: { url, events, ...clean(fields) } }); }
|
|
203
|
+
get(webhookId) { return this.c.request("GET", `/v1/webhooks/${seg(webhookId)}`); }
|
|
204
|
+
async delete(webhookId) { await this.c.request("DELETE", `/v1/webhooks/${seg(webhookId)}`); }
|
|
205
|
+
sendTest(webhookId, input = {}) { return this.c.request("POST", `/v1/webhooks/${seg(webhookId)}/test`, { json: clean(input) }); }
|
|
206
|
+
}
|
|
207
|
+
/** Stored logins. Passwords and TOTP secrets go in and never come back to an API token. */
|
|
208
|
+
class Vault {
|
|
209
|
+
c;
|
|
210
|
+
constructor(c) {
|
|
211
|
+
this.c = c;
|
|
212
|
+
}
|
|
213
|
+
list() { return this.c.request("GET", "/v1/vault/credentials"); }
|
|
214
|
+
create(input) { return this.c.request("POST", "/v1/vault/credentials", { json: clean(input) }); }
|
|
215
|
+
async delete(credentialId) { await this.c.request("DELETE", `/v1/vault/credentials/${seg(credentialId)}`); }
|
|
216
|
+
}
|
|
217
|
+
class Crawls {
|
|
218
|
+
c;
|
|
219
|
+
constructor(c) {
|
|
220
|
+
this.c = c;
|
|
221
|
+
}
|
|
222
|
+
/** `options`: `limit`, `max_depth`, `mode`, `formats`, `include_paths`, `exclude_paths`, `check_links`, `same_site`, ... */
|
|
223
|
+
start(url, options = {}) { return this.c.request("POST", "/v1/crawl", { json: { url, ...clean(options) } }); }
|
|
224
|
+
get(jobId, params = {}) { return this.c.request("GET", `/v1/crawl/${seg(jobId)}`, { params }); }
|
|
225
|
+
cancel(jobId) { return this.c.request("DELETE", `/v1/crawl/${seg(jobId)}`); }
|
|
226
|
+
async wait(jobId, options = {}) {
|
|
227
|
+
const timeoutS = options.timeoutS ?? 300;
|
|
228
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
229
|
+
for (;;) {
|
|
230
|
+
const job = await this.get(jobId, { include_content: false });
|
|
231
|
+
if (job.status === "completed" || job.status === "failed" || job.status === "cancelled")
|
|
232
|
+
return job;
|
|
233
|
+
if (Date.now() >= deadline)
|
|
234
|
+
throw new SteerpaneError(`Crawl ${jobId} had not finished after ${timeoutS} s`);
|
|
235
|
+
await sleep((options.pollS ?? 2) * 1000);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Call MCP tools over the API's Streamable HTTP endpoint. A few tools are MCP only, such as `act`, `observe` and `wait_for_code`. */
|
|
240
|
+
class Mcp {
|
|
241
|
+
c;
|
|
242
|
+
sessionId = null;
|
|
243
|
+
nextId = 0;
|
|
244
|
+
constructor(c) {
|
|
245
|
+
this.c = c;
|
|
246
|
+
}
|
|
247
|
+
async post(method, params, options = {}) {
|
|
248
|
+
const id = options.notify ? undefined : ++this.nextId;
|
|
249
|
+
const headers = { Accept: "application/json, text/event-stream", "Mcp-Protocol-Version": MCP_PROTOCOL };
|
|
250
|
+
if (this.sessionId)
|
|
251
|
+
headers["Mcp-Session-Id"] = this.sessionId;
|
|
252
|
+
const res = await this.c.rawRequest("POST", "/mcp", { json: { jsonrpc: "2.0", method, params, ...(id ? { id } : {}) }, headers, timeoutMs: options.timeoutMs });
|
|
253
|
+
if (res.status >= 400)
|
|
254
|
+
throw errorFromResponse(res.status, await parse(res), retryAfterOf(res));
|
|
255
|
+
this.sessionId = res.headers.get("mcp-session-id") ?? this.sessionId;
|
|
256
|
+
if (options.notify)
|
|
257
|
+
return null;
|
|
258
|
+
const text = await res.text();
|
|
259
|
+
let reply;
|
|
260
|
+
if ((res.headers.get("content-type") ?? "").includes("event-stream")) {
|
|
261
|
+
const frames = text.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).filter(Boolean);
|
|
262
|
+
reply = frames.map((f) => JSON.parse(f)).find((m) => m.id === id);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
reply = JSON.parse(text);
|
|
266
|
+
}
|
|
267
|
+
if (!reply)
|
|
268
|
+
throw new SteerpaneError(`MCP ${method}: the server sent no reply`);
|
|
269
|
+
if (reply.error)
|
|
270
|
+
throw new SteerpaneError(`MCP ${method}: ${reply.error.message}`);
|
|
271
|
+
return reply.result ?? null;
|
|
272
|
+
}
|
|
273
|
+
async ensureSession() {
|
|
274
|
+
if (this.sessionId)
|
|
275
|
+
return;
|
|
276
|
+
await this.post("initialize", { protocolVersion: MCP_PROTOCOL, capabilities: {}, clientInfo: { name: "steerpane-sdk-node", version: VERSION } });
|
|
277
|
+
await this.post("notifications/initialized", {}, { notify: true });
|
|
278
|
+
}
|
|
279
|
+
/** The default list: core tools plus `search_tools` and `call_tool`. Any tool still runs by name with `call`. */
|
|
280
|
+
async listTools() {
|
|
281
|
+
await this.ensureSession();
|
|
282
|
+
return (await this.post("tools/list", {}))?.tools ?? [];
|
|
283
|
+
}
|
|
284
|
+
/** Run a tool and return its JSON result. A tool failure throws `ToolError` with the API's error code. */
|
|
285
|
+
async call(tool, args = {}, options = {}) {
|
|
286
|
+
await this.ensureSession();
|
|
287
|
+
const result = await this.post("tools/call", { name: tool, arguments: clean(args) }, { timeoutMs: (options.timeoutS ?? 120) * 1000 });
|
|
288
|
+
const text = result?.content?.find((c) => c.type === "text")?.text;
|
|
289
|
+
let payload = result?.structuredContent;
|
|
290
|
+
if (payload === undefined && text) {
|
|
291
|
+
try {
|
|
292
|
+
payload = JSON.parse(text);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
payload = text;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (result?.isError) {
|
|
299
|
+
const err = payload?.error;
|
|
300
|
+
throw new ToolError(tool, err?.code ?? "tool_error", err?.message ?? text ?? "Tool failed", err?.details ?? {});
|
|
301
|
+
}
|
|
302
|
+
return payload;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Client for the Steerpane API. */
|
|
306
|
+
export class Steerpane {
|
|
307
|
+
baseUrl;
|
|
308
|
+
timeoutMs;
|
|
309
|
+
maxRetries;
|
|
310
|
+
fetchImpl;
|
|
311
|
+
sessions = new Sessions(this);
|
|
312
|
+
actions = new Actions(this);
|
|
313
|
+
profiles = new Profiles(this);
|
|
314
|
+
runs = new Runs(this);
|
|
315
|
+
files = new Files(this);
|
|
316
|
+
embeds = new Embeds(this);
|
|
317
|
+
webhooks = new Webhooks(this);
|
|
318
|
+
vault = new Vault(this);
|
|
319
|
+
crawls = new Crawls(this);
|
|
320
|
+
mcp = new Mcp(this);
|
|
321
|
+
constructor(options = {}) {
|
|
322
|
+
const token = options.token ?? process.env["STEERPANE_API_TOKEN"];
|
|
323
|
+
if (!token)
|
|
324
|
+
throw new SteerpaneError("No API token. Pass token or set STEERPANE_API_TOKEN.");
|
|
325
|
+
Object.defineProperty(this, "token", { value: token, enumerable: false });
|
|
326
|
+
this.baseUrl = (options.baseUrl ?? process.env["STEERPANE_API_URL"] ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
327
|
+
let url;
|
|
328
|
+
try {
|
|
329
|
+
url = new URL(this.baseUrl);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
throw new SteerpaneError(`Invalid API URL: ${this.baseUrl}`);
|
|
333
|
+
}
|
|
334
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
335
|
+
throw new SteerpaneError(`Invalid API URL: ${this.baseUrl}`);
|
|
336
|
+
if (url.protocol === "http:" && !LOCAL_HOSTS.has(url.hostname) && !options.allowInsecureHttp) {
|
|
337
|
+
throw new SteerpaneError("Refusing to send an API token over plain http. Use https, or pass allowInsecureHttp: true.");
|
|
338
|
+
}
|
|
339
|
+
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
340
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
341
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
342
|
+
}
|
|
343
|
+
/** One HTTP call with your token and the retry rules. Doesn't throw on an error status. */
|
|
344
|
+
async rawRequest(method, path, options = {}) {
|
|
345
|
+
const query = new URLSearchParams();
|
|
346
|
+
for (const [k, v] of Object.entries(options.params ?? {}))
|
|
347
|
+
if (v !== undefined && v !== null)
|
|
348
|
+
query.set(k, String(v));
|
|
349
|
+
const url = this.baseUrl + path + (query.size ? `?${query}` : "");
|
|
350
|
+
const headers = { Authorization: `Bearer ${this.token}`, "User-Agent": `steerpane-sdk-node/${VERSION}`, ...options.headers };
|
|
351
|
+
if (options.json !== undefined)
|
|
352
|
+
headers["Content-Type"] = "application/json";
|
|
353
|
+
const body = options.body ?? (options.json !== undefined ? JSON.stringify(options.json) : undefined);
|
|
354
|
+
const keyed = "Idempotency-Key" in headers;
|
|
355
|
+
const idempotent = method === "GET" || method === "HEAD" || method === "DELETE" || keyed;
|
|
356
|
+
for (let attempt = 0;; attempt++) {
|
|
357
|
+
let delay;
|
|
358
|
+
try {
|
|
359
|
+
const res = await this.fetchImpl(url, { method, headers, body, signal: AbortSignal.timeout(options.timeoutMs ?? this.timeoutMs) });
|
|
360
|
+
// A stored 429 replays under an Idempotency-Key, so a keyed POST isn't retried on it. A rate-limited request never ran.
|
|
361
|
+
const rateLimited = res.status === 429 && !keyed && (await isRateLimited(res));
|
|
362
|
+
if (!((idempotent && RETRY_STATUSES.has(res.status)) || rateLimited) || attempt >= this.maxRetries)
|
|
363
|
+
return res;
|
|
364
|
+
delay = retryAfterOf(res) ?? 0.5 * 2 ** attempt;
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
const timedOut = e instanceof Error && e.name === "TimeoutError";
|
|
368
|
+
if (!idempotent || timedOut || attempt >= this.maxRetries)
|
|
369
|
+
throw e;
|
|
370
|
+
delay = 0.5 * 2 ** attempt;
|
|
371
|
+
}
|
|
372
|
+
await sleep(Math.min(delay, 30) * 1000);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Call any endpoint, for what this client has no method for. Returns the parsed body, or throws `APIError`. */
|
|
376
|
+
async request(method, path, options = {}) {
|
|
377
|
+
const res = await this.rawRequest(method, path, options);
|
|
378
|
+
const parsed = await parse(res);
|
|
379
|
+
if (res.status >= 300)
|
|
380
|
+
throw errorFromResponse(res.status, parsed, retryAfterOf(res));
|
|
381
|
+
return parsed;
|
|
382
|
+
}
|
|
383
|
+
/** Every item across pages: `for await (const s of client.paginate((p) => client.sessions.list({ ...p, status: "live" })))`. */
|
|
384
|
+
async *paginate(listCall) {
|
|
385
|
+
let cursor = null;
|
|
386
|
+
do {
|
|
387
|
+
const page = await listCall({ cursor });
|
|
388
|
+
yield* page.data;
|
|
389
|
+
cursor = page.next_cursor ?? null;
|
|
390
|
+
} while (cursor);
|
|
391
|
+
}
|
|
392
|
+
/** Fetch a page without a browser session. `mode` is `fast`, `deep` or `auto`. The site's own status is in the result's `status`. */
|
|
393
|
+
fetch(url, options = {}) { return this.request("POST", "/v1/fetch", { json: { url, ...clean(options) } }); }
|
|
394
|
+
fetchBatch(urls, options = {}) { return this.request("POST", "/v1/fetch/batch", { json: { urls, ...clean(options) }, timeoutMs: Math.max(this.timeoutMs, 180_000) }); }
|
|
395
|
+
search(query, options = {}) { return this.request("POST", "/v1/search", { json: { query, ...clean(options) } }); }
|
|
396
|
+
screenshot(url, options = {}) { return this.request("POST", "/v1/screenshot", { json: { url, ...clean(options) }, timeoutMs: Math.max(this.timeoutMs, 120_000) }); }
|
|
397
|
+
pdf(url, options = {}) { return this.request("POST", "/v1/pdf", { json: { url, ...clean(options) }, timeoutMs: Math.max(this.timeoutMs, 120_000) }); }
|
|
398
|
+
map(url, options = {}) { return this.request("POST", "/v1/map", { json: { url, ...clean(options) }, timeoutMs: Math.max(this.timeoutMs, 120_000) }); }
|
|
399
|
+
/** Structured data from up to 10 URLs. Needs a model key on the organization. */
|
|
400
|
+
extract(input) { return this.request("POST", "/v1/extract", { json: clean(input), timeoutMs: Math.max(this.timeoutMs, 180_000) }); }
|
|
401
|
+
}
|
|
402
|
+
async function isRateLimited(res) {
|
|
403
|
+
try {
|
|
404
|
+
const body = (await res.clone().json());
|
|
405
|
+
return body.error?.code === "rate_limited";
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
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
|
+
}
|