@unbrowse/sdk 12.1.0 → 12.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,17 @@ an OAuth access token); `baseUrl` takes an origin or an `/api/v1` URL and defaul
37
37
  | `answer(runId, { field: value })` | `GET /runs/:id`, `POST /runs/:id/responses`, `GET /runs/:id` |
38
38
  | `resume(runId, expectedStateRevision, responses)` | `POST /runs/:id/responses` |
39
39
  | `cancel(runId)` | `POST /runs/:id/cancel` |
40
+ | `runOnClient(request, { fetch?, onRequest?, timeoutMs? })` | `POST /runs` with `egress: "client"`, then `POST /egress/:id` per request |
41
+ | `egress(egressId)` / `answerEgress(egressId, requestId, { response } \| { error })` / `closeEgress(egressId)` | `GET` / `POST` / `DELETE /egress/:id` |
42
+
43
+ `runOnClient` sends the run's requests to the website from this machine, so the site sees your IP;
44
+ Unbrowse decides each request and reads each response, and never contacts the site itself:
45
+
46
+ ```ts
47
+ const run = await unbrowse.runOnClient({ capability: "hn.top_stories", input: { limit: 3 } });
48
+ // Your own network stack or proxy, and a look at each request before it leaves:
49
+ await unbrowse.runOnClient({ task: "search eatigo for italian" }, { fetch: myFetch, onRequest: (r) => allowed(r.url) });
50
+ ```
40
51
 
41
52
  A run is `succeeded` only when its result is verified (`verified: true`). `input_required` is not a
42
53
  failure: answer on the same run. `outcome_unknown` means a change may have happened; do not retry
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Json, RunRequest, RunView } from "./types.ts";
1
+ import type { EgressRequest, EgressResponse, EgressStep, Json, RunRequest, RunView } from "./types.ts";
2
2
  export declare const DEFAULT_BASE_URL = "https://unbrowse.ai/api/v1";
3
3
  export type UnbrowseOptions = {
4
4
  /** API key (`ub_live_…`) or OAuth access token. Defaults to `UNBROWSE_API_KEY`. Public routes need none. */
@@ -43,6 +43,29 @@ export declare class Unbrowse {
43
43
  timeoutMs?: number;
44
44
  }): Promise<RunView>;
45
45
  cancel(runId: string): Promise<any>;
46
+ /**
47
+ * Run with the site requests sent from this machine. Unbrowse chooses each request and reads each response;
48
+ * this sends them with `fetch` (yours by default) and posts the raw responses back until the run finishes.
49
+ * `onRequest` sees each request first; return `false` to refuse it (the run gets a network error).
50
+ */
51
+ runOnClient(request: Omit<RunRequest, "egress">, opts?: {
52
+ fetch?: typeof globalThis.fetch;
53
+ onRequest?: (req: EgressRequest) => boolean | void | Promise<boolean | void>;
54
+ timeoutMs?: number;
55
+ }): Promise<RunView>;
56
+ /** What a client-egress run is waiting for: the next request(s), or the finished run. */
57
+ egress(egressId: string): Promise<RunView | EgressStep>;
58
+ /** Post the site's response (or why it could not be sent) for one request; returns what comes next. */
59
+ answerEgress(egressId: string, requestId: string, answer: {
60
+ response: EgressResponse;
61
+ } | {
62
+ error: string;
63
+ }): Promise<RunView | EgressStep>;
64
+ /** Abandon a client-egress run. */
65
+ closeEgress(egressId: string): Promise<{
66
+ egressId: string;
67
+ status: "closed";
68
+ }>;
46
69
  /** Your private capabilities first, then the public registry. */
47
70
  discover(query: string): Promise<any>;
48
71
  capability(id: string): Promise<any>;
@@ -125,3 +148,5 @@ export type LoginView = {
125
148
  createdAt: number;
126
149
  rotatedAt?: number;
127
150
  };
151
+ /** Whether a run answer is a client-egress step (requests to send) rather than a run. */
152
+ export declare function isEgressStep(v: unknown): v is EgressStep;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { DEFAULT_BASE_URL, Unbrowse, UnbrowseError, normalizeHost } from "./client.ts";
1
+ export { DEFAULT_BASE_URL, Unbrowse, UnbrowseError, isEgressStep, normalizeHost } from "./client.ts";
2
2
  export type { LoginView, UnbrowseOptions } from "./client.ts";
3
3
  export type * from "./types.ts";
4
4
  export { cursorInstallLink, mcpCommands, vscodeInstallLink } from "./mcp-install.ts";
package/dist/index.js CHANGED
@@ -100,6 +100,43 @@ class Unbrowse {
100
100
  cancel(runId) {
101
101
  return this.req(`/runs/${runId}/cancel`, { method: "POST" });
102
102
  }
103
+ async runOnClient(request, opts = {}) {
104
+ const send = opts.fetch ?? ((...a) => globalThis.fetch(...a));
105
+ const deadline = Date.now() + (opts.timeoutMs ?? 600000);
106
+ let step = await this.run({ ...request, egress: "client" });
107
+ while (isEgressStep(step)) {
108
+ if (Date.now() > deadline) {
109
+ await this.closeEgress(step.egressId).catch(() => {
110
+ return;
111
+ });
112
+ throw new UnbrowseError("client-egress run did not finish in time", 408, "timeout");
113
+ }
114
+ const { egressId } = step;
115
+ for (const req of step.requests) {
116
+ let answer;
117
+ try {
118
+ if (await opts.onRequest?.(req) === false)
119
+ throw new Error("refused by onRequest");
120
+ answer = { response: await sendEgress(send, req) };
121
+ } catch (err) {
122
+ answer = { error: err instanceof Error ? err.message : String(err) };
123
+ }
124
+ step = await this.answerEgress(egressId, req.id, answer);
125
+ if (!isEgressStep(step))
126
+ break;
127
+ }
128
+ }
129
+ return step;
130
+ }
131
+ egress(egressId) {
132
+ return this.req(`/egress/${egressId}`);
133
+ }
134
+ answerEgress(egressId, requestId, answer) {
135
+ return this.post(`/egress/${egressId}`, { requestId, ...answer });
136
+ }
137
+ closeEgress(egressId) {
138
+ return this.req(`/egress/${egressId}`, { method: "DELETE" });
139
+ }
103
140
  discover(query) {
104
141
  return this.post("/capabilities/search", { query });
105
142
  }
@@ -161,6 +198,32 @@ function normalizeHost(site) {
161
198
  host = new URL(host).hostname;
162
199
  return host.replace(/\/.*$/, "").replace(/^www\./, "");
163
200
  }
201
+ function isEgressStep(v) {
202
+ return !!v && typeof v === "object" && v.status === "egress_required";
203
+ }
204
+ async function sendEgress(send, req) {
205
+ const body = req.body === undefined ? undefined : req.bodyEncoding === "base64" ? base64ToBytes(req.body) : req.body;
206
+ const res = await send(req.url, { method: req.method, headers: req.headers, redirect: req.redirect, ...body !== undefined ? { body } : {} });
207
+ const headers = [];
208
+ res.headers.forEach((value, name) => headers.push([name, value]));
209
+ const cookies = res.headers.getSetCookie?.();
210
+ const pairs = cookies?.length ? [...headers.filter(([n]) => n.toLowerCase() !== "set-cookie"), ...cookies.map((c) => ["set-cookie", c])] : headers;
211
+ const bytes = new Uint8Array(await res.arrayBuffer());
212
+ return { status: res.status, headers: pairs, body: bytesToBase64(bytes), bodyEncoding: "base64", ...res.url ? { url: res.url } : {} };
213
+ }
214
+ function base64ToBytes(b64) {
215
+ const bin = atob(b64);
216
+ const out = new Uint8Array(bin.length);
217
+ for (let i = 0;i < bin.length; i++)
218
+ out[i] = bin.charCodeAt(i);
219
+ return out;
220
+ }
221
+ function bytesToBase64(bytes) {
222
+ let bin = "";
223
+ for (let i = 0;i < bytes.length; i += 32768)
224
+ bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
225
+ return btoa(bin);
226
+ }
164
227
  // src/mcp-install.ts
165
228
  function cursorInstallLink(url) {
166
229
  return `cursor://anysphere.cursor-deeplink/mcp/install?name=unbrowse&config=${encodeURIComponent(btoa(JSON.stringify({ url })))}`;
@@ -262,6 +325,7 @@ export {
262
325
  UnbrowseError,
263
326
  UnbrowseMcp,
264
327
  cursorInstallLink,
328
+ isEgressStep,
265
329
  mcpCommands,
266
330
  normalizeHost,
267
331
  parseRpcBody,
package/dist/types.d.ts CHANGED
@@ -55,6 +55,39 @@ export type RunRequest = {
55
55
  maxOperations?: number;
56
56
  };
57
57
  executionPlane?: DeploymentProfile;
58
+ /** `client`: you send the site requests from your own IP (see `Unbrowse.runOnClient`). Default `server`. */
59
+ egress?: "server" | "client";
60
+ };
61
+ /** A site request for you to send, in a client-egress run. */
62
+ export type EgressRequest = {
63
+ /** Answer with this as `requestId`. */
64
+ id: string;
65
+ method: string;
66
+ url: string;
67
+ headers: Record<string, string>;
68
+ body?: string;
69
+ bodyEncoding?: "text" | "base64";
70
+ /** `manual`: do not follow redirects; return the 3xx as it came. */
71
+ redirect: "follow" | "manual";
72
+ /** The same request as a curl command. */
73
+ curl: string;
74
+ };
75
+ /** A client-egress run waiting for you to send its next site request(s). */
76
+ export type EgressStep = {
77
+ status: "egress_required";
78
+ egressId: string;
79
+ requests: EgressRequest[];
80
+ };
81
+ /** The site's response to one request, as you got it. */
82
+ export type EgressResponse = {
83
+ status: number;
84
+ /** `[name, value]` pairs keep repeated headers (set-cookie). */
85
+ headers?: [string, string][] | Record<string, string | string[]>;
86
+ /** Decoded body (after gzip/brotli): text, or base64 with `bodyEncoding: "base64"`. Up to 10 MB. */
87
+ body?: string;
88
+ bodyEncoding?: "text" | "base64";
89
+ /** The final URL, if you followed redirects. */
90
+ url?: string;
58
91
  };
59
92
  export type RunView = {
60
93
  runId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unbrowse/sdk",
3
- "version": "12.1.0",
3
+ "version": "12.1.1",
4
4
  "description": "TypeScript client for the Unbrowse API (unbrowse.ai/api/v1): runs, capabilities, learning, logins and the public site registry.",
5
5
  "license": "MIT",
6
6
  "type": "module",