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 +134 -2
- package/dist/client.d.ts +10 -0
- package/dist/client.js +157 -0
- package/dist/errors.d.ts +77 -0
- package/dist/errors.js +104 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/protocol.d.ts +8 -0
- package/dist/protocol.js +133 -0
- package/dist/resources.d.ts +59 -0
- package/dist/resources.js +472 -0
- package/dist/transport.d.ts +44 -0
- package/dist/transport.js +384 -0
- package/dist/types.d.ts +186 -0
- package/dist/types.js +1 -0
- package/dist/updates.d.ts +4 -0
- package/dist/updates.js +111 -0
- package/package.json +37 -3
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { APIError, CancelledError, ConfigurationError, NetworkError, ProtocolError, RequestTimeoutError, ResultUnknownError, redact, TabSailError, } from "./errors.js";
|
|
2
|
+
export function positive(value, name) {
|
|
3
|
+
if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647)
|
|
4
|
+
throw new ConfigurationError(`${name} must be a positive number of milliseconds.`, { code: "INVALID_CONFIGURATION" });
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
export function retries(value) {
|
|
8
|
+
if (!Number.isInteger(value) || value < 0 || value > 10)
|
|
9
|
+
throw new ConfigurationError("maxRetries must be an integer between 0 and 10.", { code: "INVALID_CONFIGURATION" });
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function operationKey(value) {
|
|
13
|
+
const key = value ?? crypto.randomUUID();
|
|
14
|
+
if (!key || key.length > 128 || /[^\x20-\x7e]/.test(key))
|
|
15
|
+
throw new ConfigurationError("idempotencyKey must contain 1–128 printable ASCII characters.", { code: "INVALID_IDEMPOTENCY_KEY" });
|
|
16
|
+
return key;
|
|
17
|
+
}
|
|
18
|
+
export function checkCancelled(signal, sessionId) {
|
|
19
|
+
if (signal?.aborted)
|
|
20
|
+
throw new CancelledError("The caller cancelled the operation.", {
|
|
21
|
+
code: "CANCELLED",
|
|
22
|
+
sessionId,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Retry-After is a floor; jitter must still spread callers when it is present. */
|
|
26
|
+
export function retryDelay(attempt, retryAfterMs = 0, maximumBackoffMs = 5_000, random = Math.random()) {
|
|
27
|
+
const window = Math.min(maximumBackoffMs, 250 * 2 ** Math.min(attempt, 30));
|
|
28
|
+
return Math.max(0, retryAfterMs) + window * (0.5 + random * 0.5);
|
|
29
|
+
}
|
|
30
|
+
export async function sleep(milliseconds, signal, sessionId) {
|
|
31
|
+
checkCancelled(signal, sessionId);
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
const timer = setTimeout(done, milliseconds);
|
|
34
|
+
function done() {
|
|
35
|
+
signal?.removeEventListener("abort", abort);
|
|
36
|
+
resolve();
|
|
37
|
+
}
|
|
38
|
+
function abort() {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
signal?.removeEventListener("abort", abort);
|
|
41
|
+
reject(new CancelledError("The caller cancelled the operation.", {
|
|
42
|
+
code: "CANCELLED",
|
|
43
|
+
sessionId,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
47
|
+
if (signal?.aborted)
|
|
48
|
+
abort();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** A streaming download. Consume its body or explicitly close it. */
|
|
52
|
+
export class FileDownload {
|
|
53
|
+
body;
|
|
54
|
+
headers;
|
|
55
|
+
status;
|
|
56
|
+
#reader;
|
|
57
|
+
#finish;
|
|
58
|
+
#abort;
|
|
59
|
+
#closed = false;
|
|
60
|
+
constructor(response, finish, abort, failure) {
|
|
61
|
+
if (!response.body)
|
|
62
|
+
throw new ProtocolError("Download response has no body.", {
|
|
63
|
+
code: "INVALID_RESPONSE",
|
|
64
|
+
});
|
|
65
|
+
this.headers = response.headers;
|
|
66
|
+
this.status = response.status;
|
|
67
|
+
this.#reader = response.body.getReader();
|
|
68
|
+
this.#finish = finish;
|
|
69
|
+
this.#abort = abort;
|
|
70
|
+
this.body = new ReadableStream({
|
|
71
|
+
pull: async (controller) => {
|
|
72
|
+
try {
|
|
73
|
+
const result = await this.#reader.read();
|
|
74
|
+
if (result.done) {
|
|
75
|
+
this.#closed = true;
|
|
76
|
+
this.#finish();
|
|
77
|
+
controller.close();
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
controller.enqueue(result.value);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
this.#closed = true;
|
|
84
|
+
this.#finish();
|
|
85
|
+
controller.error(failure(error));
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
cancel: () => this.close(),
|
|
89
|
+
}, { highWaterMark: 0 });
|
|
90
|
+
}
|
|
91
|
+
async close() {
|
|
92
|
+
if (this.#closed)
|
|
93
|
+
return;
|
|
94
|
+
this.#closed = true;
|
|
95
|
+
this.#abort();
|
|
96
|
+
try {
|
|
97
|
+
await this.#reader.cancel();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* Aborting closes the network stream as well. */
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
this.#finish();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async [Symbol.asyncDispose]() {
|
|
107
|
+
await this.close();
|
|
108
|
+
}
|
|
109
|
+
async *[Symbol.asyncIterator]() {
|
|
110
|
+
const reader = this.body.getReader();
|
|
111
|
+
try {
|
|
112
|
+
while (true) {
|
|
113
|
+
const item = await reader.read();
|
|
114
|
+
if (item.done)
|
|
115
|
+
return;
|
|
116
|
+
yield item.value;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
reader.releaseLock();
|
|
121
|
+
await this.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export class Transport {
|
|
126
|
+
#apiKey;
|
|
127
|
+
#base;
|
|
128
|
+
#fetch;
|
|
129
|
+
requestTimeoutMs;
|
|
130
|
+
maxRetries;
|
|
131
|
+
constructor(options) {
|
|
132
|
+
const apiKey = options.apiKey ?? process.env.TABSAIL_API_KEY;
|
|
133
|
+
const baseURL = options.baseURL ?? process.env.TABSAIL_BASE_URL;
|
|
134
|
+
if (!apiKey?.trim() || !baseURL?.trim())
|
|
135
|
+
throw new ConfigurationError("Set baseURL and apiKey, or TABSAIL_BASE_URL and TABSAIL_API_KEY.", { code: "MISSING_CONFIGURATION" });
|
|
136
|
+
if (/[^\x20-\x7e]/.test(apiKey))
|
|
137
|
+
throw new ConfigurationError("apiKey must contain printable ASCII characters.", { code: "INVALID_CONFIGURATION" });
|
|
138
|
+
try {
|
|
139
|
+
this.#base = new URL(baseURL);
|
|
140
|
+
if (!["http:", "https:"].includes(this.#base.protocol) ||
|
|
141
|
+
this.#base.username ||
|
|
142
|
+
this.#base.password ||
|
|
143
|
+
this.#base.search ||
|
|
144
|
+
this.#base.hash ||
|
|
145
|
+
!/^\/*$/.test(this.#base.pathname))
|
|
146
|
+
throw Error();
|
|
147
|
+
this.#base.pathname = "/v1/";
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new ConfigurationError("baseURL must be an HTTP(S) instance root URL without credentials, query, or fragment.", { code: "INVALID_CONFIGURATION" });
|
|
151
|
+
}
|
|
152
|
+
this.#apiKey = apiKey;
|
|
153
|
+
// Native fetch in workerd requires the runtime's global receiver.
|
|
154
|
+
this.#fetch = (options.fetch ?? globalThis.fetch).bind(globalThis);
|
|
155
|
+
this.requestTimeoutMs = positive(options.requestTimeoutMs ?? 15_000, "requestTimeoutMs");
|
|
156
|
+
this.maxRetries = retries(options.maxRetries ?? 0);
|
|
157
|
+
}
|
|
158
|
+
safe(error, sessionId) {
|
|
159
|
+
if (error instanceof TabSailError)
|
|
160
|
+
return error;
|
|
161
|
+
const message = error instanceof Error
|
|
162
|
+
? `${error.name}: ${error.message}`
|
|
163
|
+
: "Unknown transport failure";
|
|
164
|
+
return new NetworkError(redact(message, [this.#apiKey]), {
|
|
165
|
+
code: "NETWORK_ERROR",
|
|
166
|
+
retryable: true,
|
|
167
|
+
sessionId,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async request(spec, options = {}) {
|
|
171
|
+
checkCancelled(options.signal, spec.sessionId);
|
|
172
|
+
const requestTimeout = positive(options.requestTimeoutMs ?? this.requestTimeoutMs, "requestTimeoutMs");
|
|
173
|
+
const maxRetries = retries(options.maxRetries ?? this.maxRetries);
|
|
174
|
+
const totalTimeout = positive(options.timeoutMs ?? requestTimeout * (maxRetries + 1) + 30_000, "timeoutMs");
|
|
175
|
+
const deadline = performance.now() + totalTimeout;
|
|
176
|
+
const jsonBody = spec.json === undefined ? undefined : JSON.stringify(spec.json);
|
|
177
|
+
let dispatched = false;
|
|
178
|
+
let lastFailure;
|
|
179
|
+
try {
|
|
180
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
181
|
+
checkCancelled(options.signal, spec.sessionId);
|
|
182
|
+
const remaining = deadline - performance.now();
|
|
183
|
+
if (remaining <= 0)
|
|
184
|
+
throw new RequestTimeoutError("The logical request deadline elapsed.", { code: "OPERATION_TIMEOUT", sessionId: spec.sessionId });
|
|
185
|
+
const controller = new AbortController();
|
|
186
|
+
let timedOut = false;
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
timedOut = true;
|
|
189
|
+
controller.abort();
|
|
190
|
+
}, Math.min(requestTimeout, remaining));
|
|
191
|
+
const abort = () => controller.abort();
|
|
192
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
193
|
+
const finish = () => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
options.signal?.removeEventListener("abort", abort);
|
|
196
|
+
};
|
|
197
|
+
const failure = (error) => options.signal?.aborted
|
|
198
|
+
? new CancelledError("The caller cancelled the operation.", {
|
|
199
|
+
code: "CANCELLED",
|
|
200
|
+
sessionId: spec.sessionId,
|
|
201
|
+
})
|
|
202
|
+
: timedOut
|
|
203
|
+
? new RequestTimeoutError("HTTP request timed out.", {
|
|
204
|
+
code: remaining <= requestTimeout
|
|
205
|
+
? "OPERATION_TIMEOUT"
|
|
206
|
+
: "REQUEST_TIMEOUT",
|
|
207
|
+
retryable: true,
|
|
208
|
+
sessionId: spec.sessionId,
|
|
209
|
+
})
|
|
210
|
+
: this.safe(error, spec.sessionId);
|
|
211
|
+
let streamOwned = false;
|
|
212
|
+
try {
|
|
213
|
+
checkCancelled(options.signal, spec.sessionId);
|
|
214
|
+
const url = new URL(spec.path, this.#base);
|
|
215
|
+
if (url.origin !== this.#base.origin ||
|
|
216
|
+
!url.pathname.startsWith("/v1/"))
|
|
217
|
+
throw new ConfigurationError("Request path must stay inside the configured API origin.", { code: "INVALID_PATH" });
|
|
218
|
+
const headers = {
|
|
219
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
220
|
+
...(spec.json === undefined
|
|
221
|
+
? {}
|
|
222
|
+
: { "Content-Type": "application/json" }),
|
|
223
|
+
...(spec.key === undefined ? {} : { "Idempotency-Key": spec.key }),
|
|
224
|
+
...spec.headers,
|
|
225
|
+
};
|
|
226
|
+
dispatched = true;
|
|
227
|
+
const response = await this.#fetch(url, {
|
|
228
|
+
method: spec.method,
|
|
229
|
+
headers,
|
|
230
|
+
redirect: "manual",
|
|
231
|
+
signal: controller.signal,
|
|
232
|
+
body: spec.json === undefined ? spec.body : jsonBody,
|
|
233
|
+
...(spec.body instanceof ReadableStream ? { duplex: "half" } : {}),
|
|
234
|
+
});
|
|
235
|
+
if (!response.ok)
|
|
236
|
+
throw await this.httpError(response, spec.sessionId);
|
|
237
|
+
if (spec.streaming) {
|
|
238
|
+
const download = new FileDownload(response, finish, () => controller.abort(), failure);
|
|
239
|
+
streamOwned = true;
|
|
240
|
+
return download;
|
|
241
|
+
}
|
|
242
|
+
if (response.status === 204) {
|
|
243
|
+
await response.body?.cancel();
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
let value;
|
|
247
|
+
try {
|
|
248
|
+
value = await response.json();
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
if (controller.signal.aborted)
|
|
252
|
+
throw failure(error);
|
|
253
|
+
throw new ProtocolError("Successful HTTP response is not valid JSON.", {
|
|
254
|
+
code: "INVALID_RESPONSE",
|
|
255
|
+
status: response.status,
|
|
256
|
+
requestId: this.sanitize(response.headers.get("x-request-id")),
|
|
257
|
+
sessionId: spec.sessionId,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
checkCancelled(options.signal, spec.sessionId);
|
|
261
|
+
try {
|
|
262
|
+
return spec.parse ? spec.parse(value) : value;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (error instanceof ProtocolError)
|
|
266
|
+
throw new ProtocolError(error.message, {
|
|
267
|
+
...error.toJSON(),
|
|
268
|
+
status: response.status,
|
|
269
|
+
requestId: this.sanitize(response.headers.get("x-request-id")),
|
|
270
|
+
sessionId: spec.sessionId,
|
|
271
|
+
});
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
lastFailure = error instanceof TabSailError ? error : failure(error);
|
|
277
|
+
if (lastFailure instanceof APIError &&
|
|
278
|
+
((lastFailure.status ?? 0) < 500 ||
|
|
279
|
+
["NO_CAPACITY", "STARTUP_BUSY"].includes(lastFailure.code)))
|
|
280
|
+
dispatched = false;
|
|
281
|
+
const retryable = spec.replayable &&
|
|
282
|
+
attempt < maxRetries &&
|
|
283
|
+
(lastFailure instanceof NetworkError ||
|
|
284
|
+
lastFailure instanceof RequestTimeoutError ||
|
|
285
|
+
(lastFailure instanceof APIError &&
|
|
286
|
+
[429, 503].includes(lastFailure.status ?? 0) &&
|
|
287
|
+
lastFailure.retryable));
|
|
288
|
+
if (!retryable || options.signal?.aborted)
|
|
289
|
+
throw lastFailure;
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
if (!streamOwned)
|
|
293
|
+
finish();
|
|
294
|
+
}
|
|
295
|
+
const backoff = retryDelay(attempt, lastFailure?.retryAfterMs);
|
|
296
|
+
if (deadline - performance.now() <= backoff)
|
|
297
|
+
throw new RequestTimeoutError("The logical request deadline elapsed before another retry.", { code: "OPERATION_TIMEOUT", sessionId: spec.sessionId });
|
|
298
|
+
await sleep(backoff, options.signal, spec.sessionId);
|
|
299
|
+
}
|
|
300
|
+
throw (lastFailure ??
|
|
301
|
+
new ProtocolError("Request did not produce a result.", {
|
|
302
|
+
code: "INVALID_RESPONSE",
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
const safe = this.safe(error, spec.sessionId);
|
|
307
|
+
const uncertainHTTP = safe instanceof APIError &&
|
|
308
|
+
(safe.status ?? 0) >= 500 &&
|
|
309
|
+
!["NO_CAPACITY", "STARTUP_BUSY"].includes(safe.code);
|
|
310
|
+
if (spec.uncertain &&
|
|
311
|
+
dispatched &&
|
|
312
|
+
(!(safe instanceof APIError) || uncertainHTTP))
|
|
313
|
+
throw new ResultUnknownError(spec.uncertain, spec.key, safe, spec.sessionId);
|
|
314
|
+
throw safe;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
sanitize(value) {
|
|
318
|
+
return value ? redact(value, [this.#apiKey]) : undefined;
|
|
319
|
+
}
|
|
320
|
+
async httpError(response, sessionId) {
|
|
321
|
+
// Bound error body consumption. A gateway can return arbitrary HTML or an endless body.
|
|
322
|
+
const reader = response.body?.getReader();
|
|
323
|
+
const parts = [];
|
|
324
|
+
let bytes = 0;
|
|
325
|
+
if (reader) {
|
|
326
|
+
try {
|
|
327
|
+
while (bytes < 8192) {
|
|
328
|
+
const item = await reader.read();
|
|
329
|
+
if (item.done)
|
|
330
|
+
break;
|
|
331
|
+
const part = item.value.slice(0, 8192 - bytes);
|
|
332
|
+
parts.push(part);
|
|
333
|
+
bytes += part.length;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
/* Preserve known HTTP status and headers when the error body is interrupted. */
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
try {
|
|
341
|
+
await reader.cancel();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
/* Keep HTTP evidence. */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const joined = new Uint8Array(bytes);
|
|
349
|
+
let offset = 0;
|
|
350
|
+
for (const part of parts) {
|
|
351
|
+
joined.set(part, offset);
|
|
352
|
+
offset += part.length;
|
|
353
|
+
}
|
|
354
|
+
let envelope = {};
|
|
355
|
+
try {
|
|
356
|
+
const value = JSON.parse(new TextDecoder().decode(joined));
|
|
357
|
+
if (value && typeof value === "object")
|
|
358
|
+
envelope =
|
|
359
|
+
value.error && typeof value.error === "object" ? value.error : value;
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
/* HTML, empty, and malformed JSON retain status and headers. */
|
|
363
|
+
}
|
|
364
|
+
const retryAfter = this.sanitize(response.headers.get("retry-after"));
|
|
365
|
+
const seconds = retryAfter ? Number(retryAfter) : NaN;
|
|
366
|
+
const date = retryAfter ? Date.parse(retryAfter) : NaN;
|
|
367
|
+
const retryAfterMs = Number.isFinite(seconds) && seconds >= 0
|
|
368
|
+
? seconds * 1000
|
|
369
|
+
: Number.isFinite(date)
|
|
370
|
+
? Math.max(0, date - Date.now())
|
|
371
|
+
: undefined;
|
|
372
|
+
return new APIError(this.sanitize(typeof envelope.message === "string" ? envelope.message : undefined) ?? `HTTP ${response.status} returned an unsuccessful response.`, {
|
|
373
|
+
status: response.status,
|
|
374
|
+
code: this.sanitize(typeof envelope.code === "string" ? envelope.code : undefined) ?? `HTTP_${response.status}`,
|
|
375
|
+
retryable: envelope.retryable === true,
|
|
376
|
+
requestId: this.sanitize(typeof envelope.requestId === "string"
|
|
377
|
+
? envelope.requestId
|
|
378
|
+
: response.headers.get("x-request-id")),
|
|
379
|
+
retryAfter,
|
|
380
|
+
retryAfterMs,
|
|
381
|
+
sessionId,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
export type Region = "west" | "asia";
|
|
2
|
+
export type SessionStatus = "starting" | "ready" | "closing" | "closed" | "failed";
|
|
3
|
+
export type Controller = "automation" | "human" | "none";
|
|
4
|
+
export type HandoffStatus = "requested" | "active" | "returning" | "completed" | "cancelled";
|
|
5
|
+
export type FileStatus = "uploading" | "downloading" | "ready" | "failed" | "deleted";
|
|
6
|
+
export interface ResourceError {
|
|
7
|
+
code: string;
|
|
8
|
+
message?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HandoffSummary {
|
|
11
|
+
id: string;
|
|
12
|
+
status: HandoffStatus;
|
|
13
|
+
reason: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
completedAt: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface Handoff extends HandoffSummary {
|
|
18
|
+
browserSessionId: string;
|
|
19
|
+
}
|
|
20
|
+
export interface BrowserSession {
|
|
21
|
+
id: string;
|
|
22
|
+
projectId: string;
|
|
23
|
+
region: Region;
|
|
24
|
+
status: SessionStatus;
|
|
25
|
+
controller: Controller;
|
|
26
|
+
/** Decimal bigint string. Never convert this value to a JS Number. */
|
|
27
|
+
controlEpoch: string;
|
|
28
|
+
/** The active handoff only; completed handoffs remain in latestHandoff. */
|
|
29
|
+
handoffId: string | null;
|
|
30
|
+
latestHandoff: HandoffSummary | null;
|
|
31
|
+
timeoutSeconds: number;
|
|
32
|
+
idleTimeoutSeconds?: number | null;
|
|
33
|
+
lastActivityAt?: string | null;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
startupDeadlineAt: string;
|
|
36
|
+
startedAt: string | null;
|
|
37
|
+
expiresAt: string | null;
|
|
38
|
+
endedAt: string | null;
|
|
39
|
+
releasedAt: string | null;
|
|
40
|
+
closeReason: string | null;
|
|
41
|
+
downloadDirectory: string | null;
|
|
42
|
+
error: ResourceError | null;
|
|
43
|
+
metadata: Record<string, string>;
|
|
44
|
+
browser?: BrowserOptions;
|
|
45
|
+
profile?: {
|
|
46
|
+
id: string;
|
|
47
|
+
persistOnClose: boolean;
|
|
48
|
+
saveStatus: "not_requested" | "pending" | "saved" | "failed";
|
|
49
|
+
saveError: ResourceError | null;
|
|
50
|
+
} | null;
|
|
51
|
+
}
|
|
52
|
+
export interface BrowserOptions {
|
|
53
|
+
locale?: string;
|
|
54
|
+
timezone?: string;
|
|
55
|
+
userAgent?: string;
|
|
56
|
+
headless?: boolean;
|
|
57
|
+
geolocation?: {
|
|
58
|
+
latitude: number;
|
|
59
|
+
longitude: number;
|
|
60
|
+
accuracy?: number;
|
|
61
|
+
};
|
|
62
|
+
permissions?: {
|
|
63
|
+
origin: string;
|
|
64
|
+
permissions: ("geolocation" | "notifications" | "clipboard-read" | "clipboard-write")[];
|
|
65
|
+
}[];
|
|
66
|
+
extensions?: "block-images"[];
|
|
67
|
+
}
|
|
68
|
+
export interface Profile {
|
|
69
|
+
id: string;
|
|
70
|
+
projectId: string;
|
|
71
|
+
name: string;
|
|
72
|
+
createdAt: string;
|
|
73
|
+
updatedAt: string;
|
|
74
|
+
version: number;
|
|
75
|
+
inUse: boolean;
|
|
76
|
+
}
|
|
77
|
+
export interface SessionCreateParams {
|
|
78
|
+
browser?: BrowserOptions;
|
|
79
|
+
profile?: {
|
|
80
|
+
id: string;
|
|
81
|
+
persistOnClose?: boolean;
|
|
82
|
+
};
|
|
83
|
+
region?: "auto" | Region;
|
|
84
|
+
timeoutSeconds?: number;
|
|
85
|
+
idleTimeoutSeconds?: number | null;
|
|
86
|
+
viewport?: {
|
|
87
|
+
width: number;
|
|
88
|
+
height: number;
|
|
89
|
+
};
|
|
90
|
+
metadata?: Record<string, string>;
|
|
91
|
+
}
|
|
92
|
+
export interface SessionUpdateParams {
|
|
93
|
+
/** New total running lifetime from startedAt, 60–3600 seconds; cannot decrease. */
|
|
94
|
+
timeoutSeconds?: number;
|
|
95
|
+
/** null disables idle expiry. Reading state or viewing Live does not renew it. */
|
|
96
|
+
idleTimeoutSeconds?: number | null;
|
|
97
|
+
}
|
|
98
|
+
export type ConnectionCreateParams = {
|
|
99
|
+
type: "cdp";
|
|
100
|
+
} | {
|
|
101
|
+
type: "live";
|
|
102
|
+
mode: "view";
|
|
103
|
+
} | {
|
|
104
|
+
type: "live";
|
|
105
|
+
mode: "control";
|
|
106
|
+
handoffId: string;
|
|
107
|
+
};
|
|
108
|
+
export interface ConnectionGrant {
|
|
109
|
+
type: "cdp" | "live";
|
|
110
|
+
mode?: "view" | "control";
|
|
111
|
+
/** Secret grant URL. SDK inspection/JSON serialization redacts this field. */
|
|
112
|
+
url: string;
|
|
113
|
+
expiresAt: string;
|
|
114
|
+
}
|
|
115
|
+
export interface SessionFile {
|
|
116
|
+
id: string;
|
|
117
|
+
origin: "upload" | "download";
|
|
118
|
+
status: FileStatus;
|
|
119
|
+
filename: string;
|
|
120
|
+
contentType: string;
|
|
121
|
+
sizeBytes: number;
|
|
122
|
+
/** A path inside the remote browser, not a local filename. */
|
|
123
|
+
browserPath: string;
|
|
124
|
+
error: ResourceError | null;
|
|
125
|
+
createdAt: string;
|
|
126
|
+
}
|
|
127
|
+
export interface Page<T> {
|
|
128
|
+
items: T[];
|
|
129
|
+
nextCursor: string | null;
|
|
130
|
+
}
|
|
131
|
+
export interface SessionListParams {
|
|
132
|
+
limit?: number;
|
|
133
|
+
status?: SessionStatus;
|
|
134
|
+
cursor?: string;
|
|
135
|
+
}
|
|
136
|
+
export interface FileListParams {
|
|
137
|
+
limit?: number;
|
|
138
|
+
origin?: "upload" | "download";
|
|
139
|
+
cursor?: string;
|
|
140
|
+
}
|
|
141
|
+
export interface RequestOptions {
|
|
142
|
+
signal?: AbortSignal;
|
|
143
|
+
/** Per HTTP attempt, including JSON response consumption. */
|
|
144
|
+
requestTimeoutMs?: number;
|
|
145
|
+
/** Entire logical request, including retries and backoff. */
|
|
146
|
+
timeoutMs?: number;
|
|
147
|
+
maxRetries?: number;
|
|
148
|
+
/** Used only by session/handoff creation. */
|
|
149
|
+
idempotencyKey?: string;
|
|
150
|
+
}
|
|
151
|
+
export interface WaitOptions extends Omit<RequestOptions, "idempotencyKey"> {
|
|
152
|
+
pollIntervalMs?: number;
|
|
153
|
+
}
|
|
154
|
+
export interface HandoffCheckpoint {
|
|
155
|
+
controlEpoch: string;
|
|
156
|
+
latestHandoffId: string | null;
|
|
157
|
+
}
|
|
158
|
+
export interface HandoffWaitOptions extends WaitOptions {
|
|
159
|
+
after: HandoffCheckpoint;
|
|
160
|
+
}
|
|
161
|
+
export interface WithSessionOptions extends SessionCreateParams, RequestOptions {
|
|
162
|
+
/** Opt in to retrying confirmed capacity rejection within one acquisition deadline. */
|
|
163
|
+
waitForCapacity?: boolean;
|
|
164
|
+
/** Entire acquisition phase, including capacity waiting and ready. Defaults to 5 minutes when waitForCapacity is enabled. */
|
|
165
|
+
timeoutMs?: number;
|
|
166
|
+
readyTimeoutMs?: number;
|
|
167
|
+
/** Independent cleanup budget; caller cancellation does not cancel cleanup. */
|
|
168
|
+
cleanupTimeoutMs?: number;
|
|
169
|
+
pollIntervalMs?: number;
|
|
170
|
+
}
|
|
171
|
+
export interface FileUploadParams {
|
|
172
|
+
file: Blob | Uint8Array | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
|
|
173
|
+
filename: string;
|
|
174
|
+
contentType?: string;
|
|
175
|
+
/** Supply the exact content size when known for a stream. */
|
|
176
|
+
sizeBytes?: number;
|
|
177
|
+
}
|
|
178
|
+
export interface TabSailOptions {
|
|
179
|
+
/** Instance root URL; the SDK appends /v1. Defaults to TABSAIL_BASE_URL. */
|
|
180
|
+
baseURL?: string;
|
|
181
|
+
/** Defaults to TABSAIL_API_KEY. */
|
|
182
|
+
apiKey?: string;
|
|
183
|
+
requestTimeoutMs?: number;
|
|
184
|
+
maxRetries?: number;
|
|
185
|
+
fetch?: typeof globalThis.fetch;
|
|
186
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Transport } from "./transport.js";
|
|
2
|
+
import type { BrowserSession, WaitOptions } from "./types.js";
|
|
3
|
+
/** Snapshot subscription. Reconnects resume from current state, never replaying browser actions. */
|
|
4
|
+
export declare function sessionUpdates(transport: Transport, path: string, sessionId: string, options?: WaitOptions): AsyncGenerator<BrowserSession>;
|
package/dist/updates.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { APIError, NetworkError, ProtocolError, RequestTimeoutError, WaitTimeoutError, } from "./errors.js";
|
|
2
|
+
import { parseSession } from "./protocol.js";
|
|
3
|
+
import { positive, sleep, } from "./transport.js";
|
|
4
|
+
/** Snapshot subscription. Reconnects resume from current state, never replaying browser actions. */
|
|
5
|
+
export async function* sessionUpdates(transport, path, sessionId, options = {}) {
|
|
6
|
+
const deadline = performance.now() + positive(options.timeoutMs ?? 300_000, "timeoutMs");
|
|
7
|
+
const interval = positive(options.pollIntervalMs ?? 500, "pollIntervalMs");
|
|
8
|
+
let fallback = false;
|
|
9
|
+
const bounded = () => {
|
|
10
|
+
const left = deadline - performance.now();
|
|
11
|
+
if (left <= 0)
|
|
12
|
+
throw new WaitTimeoutError("The snapshot subscription deadline elapsed.", { code: "WAIT_TIMEOUT", sessionId });
|
|
13
|
+
return { ...options, timeoutMs: left };
|
|
14
|
+
};
|
|
15
|
+
// Preserve immediate terminal-state detection, including against older servers.
|
|
16
|
+
try {
|
|
17
|
+
yield await transport.request({ method: "GET", path, sessionId, replayable: true, parse: parseSession }, bounded());
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof RequestTimeoutError &&
|
|
21
|
+
(error.code === "OPERATION_TIMEOUT" || performance.now() >= deadline))
|
|
22
|
+
throw new WaitTimeoutError("The snapshot subscription deadline elapsed.", { code: "WAIT_TIMEOUT", sessionId });
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
while (true) {
|
|
26
|
+
let stream;
|
|
27
|
+
try {
|
|
28
|
+
if (fallback) {
|
|
29
|
+
await sleep(Math.min(interval, bounded().timeoutMs), options.signal, sessionId);
|
|
30
|
+
yield await transport.request({
|
|
31
|
+
method: "GET",
|
|
32
|
+
path,
|
|
33
|
+
sessionId,
|
|
34
|
+
replayable: true,
|
|
35
|
+
parse: parseSession,
|
|
36
|
+
}, bounded());
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
stream = await transport.request({
|
|
40
|
+
method: "GET",
|
|
41
|
+
path: `${path}/events`,
|
|
42
|
+
sessionId,
|
|
43
|
+
replayable: true,
|
|
44
|
+
streaming: true,
|
|
45
|
+
headers: { Accept: "text/event-stream" },
|
|
46
|
+
}, bounded());
|
|
47
|
+
if (!stream.headers.get("content-type")?.includes("text/event-stream")) {
|
|
48
|
+
fallback = true;
|
|
49
|
+
if (stream.headers.get("content-type")?.includes("application/json")) {
|
|
50
|
+
let text = "";
|
|
51
|
+
const decoder = new TextDecoder();
|
|
52
|
+
for await (const chunk of stream) {
|
|
53
|
+
text += decoder.decode(chunk, { stream: true });
|
|
54
|
+
if (text.length > 1024 * 1024)
|
|
55
|
+
throw new ProtocolError("Snapshot exceeds limit.", { sessionId });
|
|
56
|
+
}
|
|
57
|
+
let snapshot;
|
|
58
|
+
try {
|
|
59
|
+
snapshot = JSON.parse(text);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new ProtocolError("Invalid snapshot JSON.", { sessionId });
|
|
63
|
+
}
|
|
64
|
+
yield parseSession(snapshot);
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const decoder = new TextDecoder();
|
|
69
|
+
let buffer = "";
|
|
70
|
+
for await (const chunk of stream) {
|
|
71
|
+
buffer = (buffer + decoder.decode(chunk, { stream: true })).replace(/\r\n/g, "\n");
|
|
72
|
+
if (buffer.length > 1024 * 1024)
|
|
73
|
+
throw new ProtocolError("Snapshot frame exceeds limit.", {
|
|
74
|
+
sessionId,
|
|
75
|
+
});
|
|
76
|
+
while (buffer.includes("\n\n")) {
|
|
77
|
+
const end = buffer.indexOf("\n\n");
|
|
78
|
+
const event = buffer.slice(0, end);
|
|
79
|
+
buffer = buffer.slice(end + 2);
|
|
80
|
+
if (!event.split("\n").includes("event: snapshot"))
|
|
81
|
+
continue;
|
|
82
|
+
const data = event
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter((line) => line.startsWith("data:"))
|
|
85
|
+
.map((line) => line.slice(5).trimStart())
|
|
86
|
+
.join("\n");
|
|
87
|
+
let value;
|
|
88
|
+
try {
|
|
89
|
+
value = JSON.parse(data);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new ProtocolError("Invalid snapshot JSON.", { sessionId });
|
|
93
|
+
}
|
|
94
|
+
yield parseSession(value);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof APIError &&
|
|
100
|
+
[404, 405, 406].includes(error.status ?? 0))
|
|
101
|
+
fallback = true;
|
|
102
|
+
else if (!(error instanceof NetworkError || error instanceof RequestTimeoutError))
|
|
103
|
+
throw error;
|
|
104
|
+
bounded();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await stream?.close();
|
|
108
|
+
}
|
|
109
|
+
await sleep(Math.min(interval, bounded().timeoutMs), options.signal, sessionId);
|
|
110
|
+
}
|
|
111
|
+
}
|