tabsail 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/README.md CHANGED
@@ -1,3 +1,135 @@
1
- # Temporary Holding Version
1
+ # TabSail TypeScript SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Hosted endpoint: `https://tabsail.com`. Set `TABSAIL_BASE_URL=https://tabsail.com` and `TABSAIL_API_KEY` to your project key. For local or separate deployments, use that instance's root URL.
4
+
5
+ ESM client for Node.js 24. Package `tabsail`, version `0.1.0`, manages TabSail v1 browser resources. It has no runtime dependencies on Playwright, models, Docker, or platform internals.
6
+
7
+ Fund the Tenant through the console before creating a browser. At the base rate of 100 credits/hour, each billable session rounds up to whole credits (minimum 1 credit, or one per started 36 seconds). The maximum-duration budget is also rounded up before reservation; unused credits are released and platform failures are free. Raw duration remains measured in milliseconds; $1 purchases 1,000 credits. `402 INSUFFICIENT_CREDITS` and `403 BILLING_FROZEN` are final admission refusals and never enter capacity waiting. Creating another Project or Key does not add credits or concurrency.
8
+
9
+ Install from npm:
10
+
11
+ ```sh
12
+ pnpm add tabsail@0.1.0
13
+ ```
14
+
15
+ ```ts
16
+ import { TabSail } from "tabsail";
17
+ import { chromium } from "playwright"; // separately installed by your application
18
+
19
+ const client = new TabSail({
20
+ baseURL: process.env.TABSAIL_BASE_URL,
21
+ apiKey: process.env.TABSAIL_API_KEY,
22
+ });
23
+ await client.withSession({ timeoutSeconds: 900 }, async session => {
24
+ const grant = await client.sessions.connections.create(session.id, { type: "cdp" });
25
+ const browser = await chromium.connectOverCDP(grant.url);
26
+ try {
27
+ const page = browser.contexts()[0].pages()[0];
28
+ await page.goto("https://example.com");
29
+ } finally { await browser.close(); }
30
+ });
31
+ ```
32
+
33
+ `baseURL` is the instance root, such as `http://localhost:8080`; trailing slashes are normalized and `/v1` is appended. It cannot contain credentials, a path prefix, query, or fragment. Configuration defaults only to the two environment variables shown above. The SDK never searches local secret files and does not follow HTTP redirects. Keep Project Keys in your application, outside model prompts and browser pages.
34
+
35
+ For Cloudflare Workers, pass `baseURL` and `apiKey` explicitly from Worker bindings. Native fetch works without a caller wrapper, including when supplied through the optional `fetch` setting. The HTTP transport has a workerd regression using compatibility date `2026-10-12` with `nodejs_compat` and `no_nodejs_global_timers`. This does not qualify an application's CDP client, embedded viewer, or Durable Object recovery. Run `pnpm --filter tabsail test` from the repository root for the Node and workerd regressions; the workerd test intercepts all outbound requests and uses no real credentials.
36
+
37
+ The resource surface covers `sessions.create/get/list/close`, `sessions.connections.create`, `sessions.handoffs.create/get/complete`, and `sessions.files.upload/list/get/download/delete`. List responses expose `items` and `nextCursor`; pass the cursor explicitly for the next page. Create parameters omit server defaults unless you provide them. Close and complete send empty HTTP bodies.
38
+
39
+ ```ts
40
+ import { handoffCheckpoint } from "tabsail";
41
+
42
+ const stable = await client.sessions.get(sessionId); // ready + automation
43
+ const after = handoffCheckpoint(stable);
44
+ const resumed = await client.sessions.waitForHandoff(sessionId, { after, timeoutMs: 300_000 });
45
+ const nextBaseline = handoffCheckpoint(resumed);
46
+ ```
47
+
48
+ `waitForHandoff` returns a new stable `BrowserSession` after a later handoff has completed and the same resource snapshot shows automation control. It can observe a handoff that completed entirely between observations. It compares decimal `controlEpoch` strings as bigint and never reuses an already observed completed handoff. Each return supplies the next stable checkpoint. It observes the latest handoff and current control; it is not a subscription to every historical event. `handoffs.waitUntilCompleted(sessionId, handoffId)` waits for that exact handoff; its return does not promise no subsequent handoff started. Recheck stable automation before authorizing CDP.
49
+
50
+ Readiness, release and handoff helpers use an authenticated SSE snapshot subscription after the initial GET. Each reconnect reads the latest complete state; no webpage action or callback is replayed. Against older servers without the endpoint, helpers retain polling compatibility. `pollIntervalMs` controls that fallback and reconnect backoff, rather than normal update delivery.
51
+
52
+ ```ts
53
+ for await (const snapshot of client.sessions.watch(sessionId, { signal, timeoutMs: 300_000 })) {
54
+ console.log(snapshot.status, snapshot.controller);
55
+ if (snapshot.releasedAt) break;
56
+ }
57
+ ```
58
+
59
+ Breaking iteration closes the local stream. A watch does not close the remote browser. Authentication revocation, explicit cancellation and total deadlines remain enforced.
60
+
61
+ After handoff or a natural connection loss, obtain a fresh grant, reconnect the same BrowserSession, retrieve its existing context/page again, and observe the current page. The SDK never replays webpage actions or the callback. Use explicit `sessions.create` if you need a browser to remain open for a human. Disconnecting a Playwright browser does not release the remote session.
62
+
63
+ `withSession(options, callback)` owns only the session created by its logical request, waits for ready, gives the callback a typed session resource once, then closes and waits for `releasedAt`. Caller cancellation does not cancel cleanup. Unknown creation results use the original parameters and the same key within an independent cleanup budget to acquire one resource and immediately close it. If the first attempt never reached the server, recovery can briefly create a new resource. `CleanupError` preserves `primaryError`, `cleanupError`, `sessionId`, and a directly accessible `operationKey` for recovery; cleanup failure never substitutes a successful release claim. A failed resource remains occupied until `releasedAt` is present. The SDK uses native fetch and owns no separate connection pool; discarding the client does not close remote sessions.
64
+
65
+ Time values have separate roles:
66
+
67
+ | Option | Meaning | Default |
68
+ | --- | --- | --- |
69
+ | `timeoutSeconds` | Remote session lifespan | Server chooses |
70
+ | `requestTimeoutMs` | One HTTP attempt, including response consumption | 15,000 |
71
+ | Request `timeoutMs` | Entire request including retries/backoff | Attempt budget plus 30,000 |
72
+ | `withSession` `timeoutMs` | Entire create + ready acquisition phase | Separate creation and ready budgets when omitted |
73
+ | Wait `timeoutMs` | Entire polling helper including in-flight requests | Ready 60,000; release 30,000; handoff 300,000 |
74
+ | `pollIntervalMs` | Polling interval | 500 |
75
+ | `readyTimeoutMs` | Ready helper inside `withSession` | 60,000 |
76
+ | `cleanupTimeoutMs` | Independent recovery/close/release budget | 30,000 |
77
+ | `maxRetries` | Explicit extra safe HTTP attempts | 0 |
78
+
79
+ All calls accept an `AbortSignal`. Wait helpers include request duration and backoff in their total deadline. `withSession` `timeoutMs` covers creation plus waiting for ready; its readiness wait uses the smaller of `readyTimeoutMs` and the remaining total budget. Cleanup retains its independent budget after acquisition fails. The callback's own tool deadlines and cancellation are the application's responsibility through its tools/signal; the SDK never automatically reruns it. Ordinary resource `timeoutMs` covers that logical HTTP request. SDK transport waits honor cancellation immediately.
80
+
81
+ Session and handoff creation generate one fixed `Idempotency-Key` per logical call; supply `idempotencyKey` to recover across processes. Replays can return the original starting snapshot: call `get` or `waitUntilReady` to obtain current state. Explicit retries cover network failure and retryable 429/503 on reads or operations with a known idempotent contract. Exponential backoff starts with a 250 ms window, capped at 5 seconds, with a random wait between half and all of that window **added after** `Retry-After`. The server's minimum delay is never shortened; the overall deadline can end the call before another attempt. Authentication, permissions, validation, and conflicts do not retry. Connections and uploads are never automatically replayed. A lost response throws `ResultUnknownError`; 5xx mutation errors also represent an unknown result because a gateway or service can fail after acceptance. Its `originalError` preserves HTTP status and request ID. Explicit `NO_CAPACITY` and `STARTUP_BUSY` remain immediate API errors by default. Upload recovery requires inspecting the session, not sending a duplicate file.
82
+
83
+ `withSession({ waitForCapacity: true }, callback)` waits for `503 NO_CAPACITY/STARTUP_BUSY` and `429 CREATE_RATE_LIMITED`, using one key and unchanged parameters. Its backoff window grows from 250 ms to 10 seconds with the same randomization after `Retry-After`. The default total create + ready budget is 300,000 ms (override `timeoutMs`); `readyTimeoutMs` still bounds startup. Cancellation interrupts waiting. Tenant concurrency limits, insufficient credits and billing freezes are not capacity waits. No queue position or eventual allocation is promised; confirmed refusals do not create cleanup work. The callback is never replayed.
84
+
85
+ ```ts
86
+ const uploaded = await client.sessions.files.upload(sessionId, {
87
+ file: new Blob([new Uint8Array([0, 128, 255])]),
88
+ filename: "图表.bin",
89
+ });
90
+ const download = await client.sessions.files.download(sessionId, uploaded.id);
91
+ try {
92
+ for await (const bytes of download) { /* write each chunk to your destination */ }
93
+ } finally { await download.close(); }
94
+ ```
95
+
96
+ Upload accepts Blob, Uint8Array, ReadableStream, or AsyncIterable of Uint8Array. Stream input supports optional exact `sizeBytes`; multipart bodies stream without buffering the entire input. Downloads expose `body`, `headers`, `status`, an async iterator, `close`, and async disposal. Consuming the entire stream or closing it releases the local response. Breaking the SDK async iterator also closes it. Binary bytes and Unicode filenames retain their content. `browserPath` belongs to the remote browser; retrieve temporary files before session closure.
97
+
98
+ Errors export `ConfigurationError`, `APIError`, `NetworkError`, `RequestTimeoutError`, `WaitTimeoutError`, `CancelledError`, `ProtocolError`, `ResultUnknownError`, `SessionTerminatedError`, `HandoffCancelledError`, and `CleanupError`. Common context is `status`, `code`, `retryable`, `requestId`, `retryAfter`, `retryAfterMs`, and `sessionId` when known. Unknown API codes remain APIError; unknown states fail as ProtocolError. Empty/HTML gateway errors retain HTTP context; 204 needs no JSON. SDK exception messages and transport causes redact keys and URLs. Grant URL access is explicit; grant JSON/inspection redacts it. Application code should also redact third party browser errors and avoid logging a spread/copied grant or its explicit URL.
99
+
100
+ Run `pnpm --filter tabsail check`, `test`, and `test:consumer` from the repository. The consumer check packs the SDK and installs its tarball and TypeScript in a temporary directory outside the repository, then checks ESM exports, types, and lifecycle behavior. Real PG/Steel/CDP/Live acceptance is a separate platform gate.
101
+
102
+ With elastic capacity, opt into bounded acquisition with `client.withSession({ waitForCapacity: true, timeoutMs: 300000 }, callback)`. The default total acquisition budget is five minutes when enabled and includes capacity retries and readiness. It keeps one idempotency key/body; only confirmed `NO_CAPACITY` / `STARTUP_BUSY` refusals wait. Tenant quota failures return immediately. The callback and browser lifetime are separate from this acquisition deadline.
103
+
104
+ ## Browser region
105
+
106
+ Creation defaults to `region: "auto"`, using the API caller's public egress IP. Asia and Oceania use `asia`; other or unknown locations use `west`. A backend calling on behalf of users should override the region when its own location differs.
107
+
108
+ ```ts
109
+ await client.withSession({ region: "asia", waitForCapacity: true }, async session => {
110
+ console.log(session.region); // actual "asia" or "west"
111
+ });
112
+ ```
113
+
114
+ `sessions.create({ region: "west" })` also accepts the override. Region stays fixed for retries with the same idempotency key, connections, files and the entire session. Capacity waiting stays in that region; switching regions requires a new creation request and key. Use the returned connection URL for CDP/Live.
115
+
116
+ ## Browser settings, lifetime, and Profiles
117
+
118
+ With an initialized client:
119
+
120
+ ```typescript
121
+ const profile = await client.profiles.create({ name: "Report login" });
122
+ await client.withSession({
123
+ browser: { locale: "en-US", timezone: "Asia/Tokyo", headless: true },
124
+ idleTimeoutSeconds: 120,
125
+ profile: { id: profile.id, persistOnClose: true },
126
+ }, async session => {
127
+ await client.sessions.update(session.id, { timeoutSeconds: 1200 });
128
+ await client.sessions.touch(session.id);
129
+ // Connect and perform your task. The scope closes and releases the browser.
130
+ });
131
+ ```
132
+
133
+ `timeoutSeconds` in an update is the new total lifetime from `startedAt`, not extra seconds. It cannot decrease; extra budget is reserved atomically. Use `idleTimeoutSeconds: null` to disable idle expiry. GET, SSE and read-only Live do not renew idle activity.
134
+
135
+ Profiles support `create`, `list`, `get`, and `delete`. One unreleased session may use a Profile. Normal close/expiry saves cookies and localStorage when `persistOnClose` is true; inspect the ended session's `profile.saveStatus`. A failed save preserves the previous version. Profiles exclude downloads, page memory, and Agent history.
@@ -0,0 +1,10 @@
1
+ import { Profiles, Sessions } from "./resources.js";
2
+ import type { BrowserSession, TabSailOptions, WithSessionOptions } from "./types.js";
3
+ export declare class TabSail {
4
+ #private;
5
+ readonly sessions: Sessions;
6
+ readonly profiles: Profiles;
7
+ constructor(options?: TabSailOptions);
8
+ /** Creates and owns exactly one logical session; the callback runs once. */
9
+ withSession<T>(options: WithSessionOptions, callback: (session: BrowserSession) => T | Promise<T>): Promise<T>;
10
+ }
package/dist/client.js ADDED
@@ -0,0 +1,157 @@
1
+ import { APIError, CleanupError, ResultUnknownError, TabSailError, WaitTimeoutError, } from "./errors.js";
2
+ import { Profiles, Sessions } from "./resources.js";
3
+ import { checkCancelled, operationKey, positive, retryDelay, sleep, Transport, } from "./transport.js";
4
+ export class TabSail {
5
+ sessions;
6
+ profiles;
7
+ #transport;
8
+ constructor(options = {}) {
9
+ this.#transport = new Transport(options);
10
+ this.sessions = new Sessions(this.#transport);
11
+ this.profiles = new Profiles(this.#transport);
12
+ }
13
+ /** Creates and owns exactly one logical session; the callback runs once. */
14
+ async withSession(options, callback) {
15
+ checkCancelled(options.signal);
16
+ const key = operationKey(options.idempotencyKey);
17
+ const params = {};
18
+ if (options.browser !== undefined)
19
+ params.browser = options.browser;
20
+ if (options.profile !== undefined)
21
+ params.profile = options.profile;
22
+ if (options.idleTimeoutSeconds !== undefined)
23
+ params.idleTimeoutSeconds = options.idleTimeoutSeconds;
24
+ if (options.region !== undefined)
25
+ params.region = options.region;
26
+ if (options.timeoutSeconds !== undefined)
27
+ params.timeoutSeconds = options.timeoutSeconds;
28
+ if (options.viewport !== undefined)
29
+ params.viewport = options.viewport;
30
+ if (options.metadata !== undefined)
31
+ params.metadata = options.metadata;
32
+ // Snapshot parameters: recovery must not observe mutations made by the callback.
33
+ const request = JSON.parse(JSON.stringify(params));
34
+ const cleanupTimeout = positive(options.cleanupTimeoutMs ?? 30_000, "cleanupTimeoutMs");
35
+ const readyTimeout = positive(options.readyTimeoutMs ?? 60_000, "readyTimeoutMs");
36
+ const acquisitionTimeout = options.timeoutMs ?? (options.waitForCapacity ? 300_000 : undefined);
37
+ const acquisitionDeadline = acquisitionTimeout === undefined
38
+ ? undefined
39
+ : performance.now() + positive(acquisitionTimeout, "timeoutMs");
40
+ const requestOptions = {
41
+ signal: options.signal,
42
+ idempotencyKey: key,
43
+ requestTimeoutMs: options.requestTimeoutMs,
44
+ timeoutMs: options.timeoutMs,
45
+ maxRetries: options.maxRetries,
46
+ };
47
+ let session;
48
+ let unknownCreate = false;
49
+ let primaryError;
50
+ let failed = false;
51
+ let value;
52
+ let capacityAttempt = 0;
53
+ try {
54
+ while (!session) {
55
+ checkCancelled(options.signal);
56
+ const remaining = acquisitionDeadline === undefined
57
+ ? undefined
58
+ : acquisitionDeadline - performance.now();
59
+ if (remaining !== undefined && remaining <= 0)
60
+ throw new WaitTimeoutError("Browser capacity was not acquired within the total deadline.", { code: "CAPACITY_WAIT_TIMEOUT" });
61
+ try {
62
+ session = await this.sessions.create(request, {
63
+ ...requestOptions,
64
+ timeoutMs: remaining,
65
+ });
66
+ }
67
+ catch (error) {
68
+ if (!options.waitForCapacity ||
69
+ !(error instanceof APIError) ||
70
+ !error.retryable ||
71
+ !((error.status === 503 &&
72
+ ["NO_CAPACITY", "STARTUP_BUSY"].includes(error.code)) ||
73
+ (error.status === 429 && error.code === "CREATE_RATE_LIMITED")))
74
+ throw error;
75
+ const left = acquisitionDeadline - performance.now();
76
+ if (left <= 0)
77
+ throw new WaitTimeoutError("Browser capacity wait timed out.", {
78
+ code: "CAPACITY_WAIT_TIMEOUT",
79
+ });
80
+ const delay = retryDelay(capacityAttempt++, error.retryAfterMs, 10_000);
81
+ await sleep(Math.min(left, delay), options.signal);
82
+ if (delay >= left)
83
+ throw new WaitTimeoutError("Browser capacity wait timed out.", {
84
+ code: "CAPACITY_WAIT_TIMEOUT",
85
+ });
86
+ }
87
+ }
88
+ const remainingAcquisition = acquisitionDeadline === undefined
89
+ ? undefined
90
+ : acquisitionDeadline - performance.now();
91
+ if (remainingAcquisition !== undefined && remainingAcquisition <= 0)
92
+ throw new WaitTimeoutError("Owned session acquisition exceeded its total deadline.", { code: "WAIT_TIMEOUT", sessionId: session.id });
93
+ const ready = await this.sessions.waitUntilReady(session.id, {
94
+ signal: options.signal,
95
+ timeoutMs: remainingAcquisition === undefined
96
+ ? readyTimeout
97
+ : Math.min(readyTimeout, remainingAcquisition),
98
+ requestTimeoutMs: options.requestTimeoutMs,
99
+ maxRetries: options.maxRetries,
100
+ pollIntervalMs: options.pollIntervalMs,
101
+ });
102
+ checkCancelled(options.signal, session.id);
103
+ if (acquisitionDeadline !== undefined &&
104
+ performance.now() >= acquisitionDeadline)
105
+ throw new WaitTimeoutError("Owned session acquisition exceeded its total deadline.", { code: "WAIT_TIMEOUT", sessionId: session.id });
106
+ value = await callback(ready);
107
+ checkCancelled(options.signal, session.id);
108
+ }
109
+ catch (error) {
110
+ failed = true;
111
+ primaryError = error;
112
+ unknownCreate =
113
+ !session &&
114
+ error instanceof ResultUnknownError &&
115
+ error.operation === "session.create";
116
+ }
117
+ if (session || unknownCreate) {
118
+ const deadline = performance.now() + cleanupTimeout;
119
+ const remaining = () => {
120
+ const left = deadline - performance.now();
121
+ if (left <= 0)
122
+ throw new WaitTimeoutError("Owned cleanup exceeded its independent deadline.", { code: "CLEANUP_TIMEOUT", sessionId: session?.id });
123
+ return left;
124
+ };
125
+ try {
126
+ // Recovery can briefly create a resource if the original request never arrived.
127
+ // The same key ensures only one logical resource, and it is immediately closed.
128
+ if (!session)
129
+ session = await this.sessions.create(request, {
130
+ idempotencyKey: key,
131
+ timeoutMs: remaining(),
132
+ requestTimeoutMs: options.requestTimeoutMs,
133
+ maxRetries: 1,
134
+ });
135
+ await this.sessions.close(session.id, {
136
+ timeoutMs: remaining(),
137
+ requestTimeoutMs: options.requestTimeoutMs,
138
+ maxRetries: 1,
139
+ });
140
+ await this.sessions.waitUntilReleased(session.id, {
141
+ timeoutMs: remaining(),
142
+ requestTimeoutMs: options.requestTimeoutMs,
143
+ maxRetries: 1,
144
+ pollIntervalMs: options.pollIntervalMs,
145
+ });
146
+ }
147
+ catch (error) {
148
+ throw new CleanupError(failed ? primaryError : undefined, error instanceof TabSailError
149
+ ? error
150
+ : this.#transport.safe(error, session?.id), key, session?.id);
151
+ }
152
+ }
153
+ if (failed)
154
+ throw primaryError;
155
+ return value;
156
+ }
157
+ }
@@ -0,0 +1,77 @@
1
+ declare const inspect: unique symbol;
2
+ export interface ErrorContext {
3
+ status?: number;
4
+ code?: string;
5
+ retryable?: boolean;
6
+ requestId?: string;
7
+ retryAfter?: string;
8
+ retryAfterMs?: number;
9
+ sessionId?: string;
10
+ }
11
+ /** Removes grant URLs and authorization strings, even from third party causes. */
12
+ export declare function redact(value: string, secrets?: readonly string[]): string;
13
+ export declare class TabSailError extends Error {
14
+ readonly status?: number;
15
+ readonly code: string;
16
+ readonly retryable: boolean;
17
+ readonly requestId?: string;
18
+ readonly retryAfter?: string;
19
+ readonly retryAfterMs?: number;
20
+ readonly sessionId?: string;
21
+ constructor(message: string, context?: ErrorContext);
22
+ toJSON(): {
23
+ name: string;
24
+ message: string;
25
+ status: number | undefined;
26
+ code: string;
27
+ retryable: boolean;
28
+ requestId: string | undefined;
29
+ retryAfter: string | undefined;
30
+ retryAfterMs: number | undefined;
31
+ sessionId: string | undefined;
32
+ };
33
+ [inspect](): {
34
+ name: string;
35
+ message: string;
36
+ status: number | undefined;
37
+ code: string;
38
+ retryable: boolean;
39
+ requestId: string | undefined;
40
+ retryAfter: string | undefined;
41
+ retryAfterMs: number | undefined;
42
+ sessionId: string | undefined;
43
+ };
44
+ }
45
+ export declare class ConfigurationError extends TabSailError {
46
+ }
47
+ export declare class APIError extends TabSailError {
48
+ }
49
+ export declare class NetworkError extends TabSailError {
50
+ }
51
+ export declare class RequestTimeoutError extends TabSailError {
52
+ }
53
+ export declare class WaitTimeoutError extends TabSailError {
54
+ }
55
+ export declare class CancelledError extends TabSailError {
56
+ }
57
+ export declare class ProtocolError extends TabSailError {
58
+ }
59
+ export declare class SessionTerminatedError extends TabSailError {
60
+ }
61
+ export declare class HandoffCancelledError extends TabSailError {
62
+ }
63
+ /** Access operationKey directly to recover; inspection intentionally omits it. */
64
+ export declare class ResultUnknownError extends TabSailError {
65
+ readonly operation: "session.create" | "handoff.create" | "profile.create" | "file.upload" | "connection.create";
66
+ readonly operationKey?: string;
67
+ readonly originalError: TabSailError;
68
+ constructor(operation: ResultUnknownError["operation"], key: string | undefined, error: TabSailError, sessionId?: string);
69
+ }
70
+ /** The original error is retained without inserting unsafe causes into the stack. */
71
+ export declare class CleanupError extends TabSailError {
72
+ readonly primaryError: unknown;
73
+ readonly cleanupError: TabSailError;
74
+ readonly operationKey?: string;
75
+ constructor(primaryError: unknown, cleanupError: TabSailError, key: string, sessionId?: string);
76
+ }
77
+ export {};
package/dist/errors.js ADDED
@@ -0,0 +1,104 @@
1
+ const inspect = Symbol.for("nodejs.util.inspect.custom");
2
+ /** Removes grant URLs and authorization strings, even from third party causes. */
3
+ export function redact(value, secrets = []) {
4
+ let result = value;
5
+ for (const secret of secrets)
6
+ if (secret)
7
+ result = result.split(secret).join("[REDACTED]");
8
+ return result
9
+ .replace(/\b(?:https?|wss?):\/\/[^\s"'<>]+/gi, "[REDACTED_URL]")
10
+ .replace(/\bBearer\s+[^\s"'<>]+/gi, "Bearer [REDACTED]")
11
+ .replace(/\b(?:bpk|bp_key)_[A-Za-z0-9_-]+/g, "[REDACTED_KEY]")
12
+ .replace(/\bbp_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g, "[REDACTED_KEY]")
13
+ .slice(0, 1024);
14
+ }
15
+ export class TabSailError extends Error {
16
+ status;
17
+ code;
18
+ retryable;
19
+ requestId;
20
+ retryAfter;
21
+ retryAfterMs;
22
+ sessionId;
23
+ constructor(message, context = {}) {
24
+ super(redact(message));
25
+ this.name = new.target.name;
26
+ this.status = context.status;
27
+ this.code = redact(context.code ?? "SDK_ERROR");
28
+ this.retryable = context.retryable ?? false;
29
+ this.requestId = context.requestId ? redact(context.requestId) : undefined;
30
+ this.retryAfter = context.retryAfter
31
+ ? redact(context.retryAfter)
32
+ : undefined;
33
+ this.retryAfterMs = context.retryAfterMs;
34
+ this.sessionId = context.sessionId ? redact(context.sessionId) : undefined;
35
+ }
36
+ toJSON() {
37
+ return {
38
+ name: this.name,
39
+ message: this.message,
40
+ status: this.status,
41
+ code: this.code,
42
+ retryable: this.retryable,
43
+ requestId: this.requestId,
44
+ retryAfter: this.retryAfter,
45
+ retryAfterMs: this.retryAfterMs,
46
+ sessionId: this.sessionId,
47
+ };
48
+ }
49
+ [inspect]() {
50
+ return this.toJSON();
51
+ }
52
+ }
53
+ export class ConfigurationError extends TabSailError {
54
+ }
55
+ export class APIError extends TabSailError {
56
+ }
57
+ export class NetworkError extends TabSailError {
58
+ }
59
+ export class RequestTimeoutError extends TabSailError {
60
+ }
61
+ export class WaitTimeoutError extends TabSailError {
62
+ }
63
+ export class CancelledError extends TabSailError {
64
+ }
65
+ export class ProtocolError extends TabSailError {
66
+ }
67
+ export class SessionTerminatedError extends TabSailError {
68
+ }
69
+ export class HandoffCancelledError extends TabSailError {
70
+ }
71
+ /** Access operationKey directly to recover; inspection intentionally omits it. */
72
+ export class ResultUnknownError extends TabSailError {
73
+ operation;
74
+ originalError;
75
+ constructor(operation, key, error, sessionId) {
76
+ super(`${operation} result is unknown. ${key ? "Recover with the same operationKey and original parameters; " : "Inspect the session before taking further action; "}do not blindly replay non-idempotent operations.`, {
77
+ ...error.toJSON(),
78
+ code: "RESULT_UNKNOWN",
79
+ sessionId: sessionId ?? error.sessionId,
80
+ });
81
+ this.operation = operation;
82
+ Object.defineProperty(this, "operationKey", {
83
+ value: key,
84
+ enumerable: false,
85
+ });
86
+ this.originalError = error;
87
+ }
88
+ }
89
+ /** The original error is retained without inserting unsafe causes into the stack. */
90
+ export class CleanupError extends TabSailError {
91
+ cleanupError;
92
+ constructor(primaryError, cleanupError, key, sessionId) {
93
+ super("Owned session cleanup failed. Inspect sessionId or recover with operationKey and the original creation parameters, then close and wait for releasedAt.", { code: "CLEANUP_FAILED", sessionId, requestId: cleanupError.requestId });
94
+ Object.defineProperty(this, "primaryError", {
95
+ value: primaryError,
96
+ enumerable: false,
97
+ });
98
+ Object.defineProperty(this, "operationKey", {
99
+ value: key,
100
+ enumerable: false,
101
+ });
102
+ this.cleanupError = cleanupError;
103
+ }
104
+ }
@@ -0,0 +1,5 @@
1
+ export { TabSail } from "./client.js";
2
+ export * from "./errors.js";
3
+ export { handoffCheckpoint } from "./resources.js";
4
+ export { FileDownload } from "./transport.js";
5
+ export type * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { TabSail } from "./client.js";
2
+ export * from "./errors.js";
3
+ export { handoffCheckpoint } from "./resources.js";
4
+ export { FileDownload } from "./transport.js";
@@ -0,0 +1,8 @@
1
+ import type { BrowserSession, ConnectionGrant, Handoff, HandoffSummary, Page, Profile, SessionFile } from "./types.js";
2
+ export declare function parseHandoffSummary(value: unknown): HandoffSummary;
3
+ export declare function parseHandoff(value: unknown): Handoff;
4
+ export declare function parseSession(value: unknown): BrowserSession;
5
+ export declare function parseProfile(value: unknown): Profile;
6
+ export declare function parseGrant(value: unknown): ConnectionGrant;
7
+ export declare function parseFile(value: unknown): SessionFile;
8
+ export declare function parsePage<T>(parse: (value: unknown) => T): (value: unknown) => Page<T>;
@@ -0,0 +1,133 @@
1
+ import { ProtocolError } from "./errors.js";
2
+ function invalid(label) {
3
+ throw new ProtocolError(`Unexpected ${label} response.`, {
4
+ code: "INVALID_RESPONSE",
5
+ });
6
+ }
7
+ function object(value, label) {
8
+ if (!value || typeof value !== "object" || Array.isArray(value))
9
+ invalid(label);
10
+ return value;
11
+ }
12
+ function strings(row, names, label) {
13
+ if (names.some((name) => typeof row[name] !== "string"))
14
+ invalid(label);
15
+ }
16
+ function nullableStrings(row, names, label) {
17
+ if (names.some((name) => row[name] !== null && typeof row[name] !== "string"))
18
+ invalid(label);
19
+ }
20
+ function enumValue(value, choices, label) {
21
+ if (typeof value !== "string" || !choices.includes(value))
22
+ invalid(label);
23
+ }
24
+ export function parseHandoffSummary(value) {
25
+ const row = object(value, "handoff");
26
+ strings(row, ["id", "reason", "createdAt"], "handoff");
27
+ nullableStrings(row, ["completedAt"], "handoff");
28
+ enumValue(row.status, ["requested", "active", "returning", "completed", "cancelled"], "handoff status");
29
+ return row;
30
+ }
31
+ export function parseHandoff(value) {
32
+ const row = parseHandoffSummary(value);
33
+ strings(row, ["browserSessionId"], "handoff");
34
+ return row;
35
+ }
36
+ export function parseSession(value) {
37
+ const row = object(value, "session");
38
+ enumValue(row.region, ["west", "asia"], "session region");
39
+ strings(row, ["id", "projectId", "controlEpoch", "createdAt", "startupDeadlineAt"], "session");
40
+ if (!/^(0|[1-9]\d*)$/.test(row.controlEpoch))
41
+ invalid("controlEpoch");
42
+ enumValue(row.status, ["starting", "ready", "closing", "closed", "failed"], "session status");
43
+ enumValue(row.controller, ["automation", "human", "none"], "controller");
44
+ nullableStrings(row, [
45
+ "handoffId",
46
+ "startedAt",
47
+ "expiresAt",
48
+ "endedAt",
49
+ "releasedAt",
50
+ "closeReason",
51
+ "downloadDirectory",
52
+ ], "session");
53
+ if (row.latestHandoff !== null)
54
+ parseHandoffSummary(row.latestHandoff);
55
+ if (!Number.isInteger(row.timeoutSeconds) || Number(row.timeoutSeconds) < 0)
56
+ invalid("session timeout");
57
+ if (row.idleTimeoutSeconds != null &&
58
+ (!Number.isInteger(row.idleTimeoutSeconds) ||
59
+ Number(row.idleTimeoutSeconds) < 60))
60
+ invalid("idle timeout");
61
+ if (row.lastActivityAt != null && typeof row.lastActivityAt !== "string")
62
+ invalid("last activity");
63
+ if (row.profile != null) {
64
+ const profile = object(row.profile, "session Profile");
65
+ strings(profile, ["id"], "session Profile");
66
+ enumValue(profile.saveStatus, ["not_requested", "pending", "saved", "failed"], "Profile save status");
67
+ if (typeof profile.persistOnClose !== "boolean")
68
+ invalid("Profile persistence");
69
+ }
70
+ const metadata = object(row.metadata, "session metadata");
71
+ if (Object.values(metadata).some((value) => typeof value !== "string"))
72
+ invalid("session metadata");
73
+ if (row.error !== null)
74
+ strings(object(row.error, "session error"), ["code"], "session error");
75
+ return row;
76
+ }
77
+ export function parseProfile(value) {
78
+ const row = object(value, "Profile");
79
+ strings(row, ["id", "projectId", "name", "createdAt", "updatedAt"], "Profile");
80
+ if (!Number.isInteger(row.version) ||
81
+ Number(row.version) < 0 ||
82
+ typeof row.inUse !== "boolean")
83
+ invalid("Profile metadata");
84
+ return row;
85
+ }
86
+ export function parseGrant(value) {
87
+ const row = object(value, "connection grant");
88
+ strings(row, ["url", "expiresAt"], "connection grant");
89
+ enumValue(row.type, ["cdp", "live"], "connection type");
90
+ if (row.type === "live")
91
+ enumValue(row.mode, ["view", "control"], "connection mode");
92
+ try {
93
+ const url = new URL(row.url);
94
+ if (!(row.type === "cdp" ? ["ws:", "wss:"] : ["http:", "https:"]).includes(url.protocol))
95
+ invalid("connection URL");
96
+ }
97
+ catch {
98
+ invalid("connection URL");
99
+ }
100
+ const safe = { ...row, url: "[REDACTED]" };
101
+ Object.defineProperty(row, "toJSON", {
102
+ value: () => safe,
103
+ enumerable: false,
104
+ });
105
+ Object.defineProperty(row, Symbol.for("nodejs.util.inspect.custom"), {
106
+ value: () => safe,
107
+ enumerable: false,
108
+ });
109
+ return row;
110
+ }
111
+ export function parseFile(value) {
112
+ const row = object(value, "file");
113
+ strings(row, ["id", "filename", "contentType", "browserPath", "createdAt"], "file");
114
+ enumValue(row.status, ["uploading", "downloading", "ready", "failed", "deleted"], "file status");
115
+ enumValue(row.origin, ["upload", "download"], "file origin");
116
+ if (!Number.isSafeInteger(row.sizeBytes) || Number(row.sizeBytes) < 0)
117
+ invalid("file size");
118
+ if (row.error !== null)
119
+ strings(object(row.error, "file error"), ["code"], "file error");
120
+ return row;
121
+ }
122
+ export function parsePage(parse) {
123
+ return (value) => {
124
+ const row = object(value, "page");
125
+ if (!Array.isArray(row.items))
126
+ invalid("page items");
127
+ nullableStrings(row, ["nextCursor"], "page");
128
+ return {
129
+ items: row.items.map(parse),
130
+ nextCursor: row.nextCursor,
131
+ };
132
+ };
133
+ }