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.
@@ -0,0 +1,59 @@
1
+ import { type FileDownload, type Transport } from "./transport.js";
2
+ import type { BrowserSession, ConnectionCreateParams, ConnectionGrant, FileListParams, FileUploadParams, Handoff, HandoffCheckpoint, HandoffWaitOptions, Page, RequestOptions, SessionCreateParams, SessionFile, SessionListParams, SessionUpdateParams, WaitOptions } from "./types.js";
3
+ export declare class Profiles {
4
+ private transport;
5
+ constructor(transport: Transport);
6
+ create(params: {
7
+ name: string;
8
+ }, options?: RequestOptions): Promise<import("./types.js").Profile>;
9
+ get(profileId: string, options?: RequestOptions): Promise<import("./types.js").Profile>;
10
+ list(params?: {
11
+ limit?: number;
12
+ cursor?: string;
13
+ }, options?: RequestOptions): Promise<Page<import("./types.js").Profile>>;
14
+ delete(profileId: string, options?: RequestOptions): Promise<void>;
15
+ }
16
+ export declare function handoffCheckpoint(session: BrowserSession): HandoffCheckpoint;
17
+ export declare class Connections {
18
+ private transport;
19
+ constructor(transport: Transport);
20
+ create(sessionId: string, params: ConnectionCreateParams, options?: RequestOptions): Promise<ConnectionGrant>;
21
+ }
22
+ export declare class Handoffs {
23
+ private transport;
24
+ private sessions;
25
+ constructor(transport: Transport, sessions: Sessions);
26
+ create(sessionId: string, params?: {
27
+ reason?: string;
28
+ }, options?: RequestOptions): Promise<Handoff>;
29
+ get(sessionId: string, handoffId: string, options?: RequestOptions): Promise<Handoff>;
30
+ complete(sessionId: string, handoffId: string, options?: RequestOptions): Promise<Handoff>;
31
+ waitUntilCompleted(sessionId: string, handoffId: string, options?: WaitOptions): Promise<Handoff>;
32
+ }
33
+ export declare class Files {
34
+ private transport;
35
+ constructor(transport: Transport);
36
+ upload(sessionId: string, params: FileUploadParams, options?: RequestOptions): Promise<SessionFile>;
37
+ list(sessionId: string, params?: FileListParams, options?: RequestOptions): Promise<Page<SessionFile>>;
38
+ get(sessionId: string, fileId: string, options?: RequestOptions): Promise<SessionFile>;
39
+ download(sessionId: string, fileId: string, options?: RequestOptions): Promise<FileDownload>;
40
+ delete(sessionId: string, fileId: string, options?: RequestOptions): Promise<void>;
41
+ }
42
+ export declare class Sessions {
43
+ private transport;
44
+ readonly connections: Connections;
45
+ readonly handoffs: Handoffs;
46
+ readonly files: Files;
47
+ constructor(transport: Transport);
48
+ /** Latest complete state snapshots, including an initial state and reconnect resynchronization. */
49
+ watch(sessionId: string, options?: WaitOptions): AsyncGenerator<BrowserSession>;
50
+ create(params?: SessionCreateParams, options?: RequestOptions): Promise<BrowserSession>;
51
+ get(sessionId: string, options?: RequestOptions): Promise<BrowserSession>;
52
+ list(params?: SessionListParams, options?: RequestOptions): Promise<Page<BrowserSession>>;
53
+ update(sessionId: string, params: SessionUpdateParams, options?: RequestOptions): Promise<BrowserSession>;
54
+ touch(sessionId: string, options?: RequestOptions): Promise<BrowserSession>;
55
+ close(sessionId: string, options?: RequestOptions): Promise<BrowserSession>;
56
+ waitUntilReady(sessionId: string, options?: WaitOptions): Promise<BrowserSession>;
57
+ waitUntilReleased(sessionId: string, options?: WaitOptions): Promise<BrowserSession>;
58
+ waitForHandoff(sessionId: string, options: HandoffWaitOptions): Promise<BrowserSession>;
59
+ }
@@ -0,0 +1,472 @@
1
+ import { ConfigurationError, HandoffCancelledError, RequestTimeoutError, SessionTerminatedError, WaitTimeoutError, } from "./errors.js";
2
+ import { parseFile, parseGrant, parseHandoff, parsePage, parseProfile, parseSession, } from "./protocol.js";
3
+ import { checkCancelled, operationKey, positive, sleep, } from "./transport.js";
4
+ import { sessionUpdates } from "./updates.js";
5
+ export class Profiles {
6
+ transport;
7
+ constructor(transport) {
8
+ this.transport = transport;
9
+ }
10
+ create(params, options = {}) {
11
+ return this.transport.request({
12
+ method: "POST",
13
+ path: "profiles",
14
+ json: params,
15
+ key: operationKey(options.idempotencyKey),
16
+ replayable: true,
17
+ uncertain: "profile.create",
18
+ parse: parseProfile,
19
+ }, options);
20
+ }
21
+ get(profileId, options = {}) {
22
+ return this.transport.request({
23
+ method: "GET",
24
+ path: `profiles/${segment(profileId)}`,
25
+ replayable: true,
26
+ parse: parseProfile,
27
+ }, options);
28
+ }
29
+ list(params = {}, options = {}) {
30
+ return this.transport.request({
31
+ method: "GET",
32
+ path: `profiles${query(params)}`,
33
+ replayable: true,
34
+ parse: parsePage(parseProfile),
35
+ }, options);
36
+ }
37
+ delete(profileId, options = {}) {
38
+ return this.transport.request({
39
+ method: "DELETE",
40
+ path: `profiles/${segment(profileId)}`,
41
+ replayable: true,
42
+ }, options);
43
+ }
44
+ }
45
+ function segment(value) {
46
+ if (!value || value === "." || value === "..")
47
+ throw new ConfigurationError("Resource IDs must be nonempty path segments.", { code: "INVALID_PATH" });
48
+ return encodeURIComponent(value);
49
+ }
50
+ function path(session) {
51
+ return `browser-sessions/${segment(session)}`;
52
+ }
53
+ function query(values) {
54
+ const search = new URLSearchParams();
55
+ for (const [key, value] of Object.entries(values))
56
+ if (value !== undefined)
57
+ search.set(key, String(value));
58
+ return search.size ? `?${search}` : "";
59
+ }
60
+ function ended(session) {
61
+ throw new SessionTerminatedError(`BrowserSession ended in ${session.status} state.`, {
62
+ code: session.error?.code ?? "SESSION_ENDED",
63
+ sessionId: session.id,
64
+ });
65
+ }
66
+ export function handoffCheckpoint(session) {
67
+ if (session.status !== "ready" || session.controller !== "automation")
68
+ throw new ConfigurationError("A handoff checkpoint requires the same stable ready + automation snapshot.", { code: "UNSTABLE_CHECKPOINT", sessionId: session.id });
69
+ if (!/^(0|[1-9]\d*)$/.test(session.controlEpoch))
70
+ throw new ConfigurationError("controlEpoch must be a decimal bigint string.", { code: "INVALID_CHECKPOINT", sessionId: session.id });
71
+ return {
72
+ controlEpoch: session.controlEpoch,
73
+ latestHandoffId: session.latestHandoff?.id ?? null,
74
+ };
75
+ }
76
+ class WaitBudget {
77
+ options;
78
+ sessionId;
79
+ label;
80
+ deadline;
81
+ interval;
82
+ constructor(options, sessionId, label, fallback) {
83
+ this.options = options;
84
+ this.sessionId = sessionId;
85
+ this.label = label;
86
+ this.deadline =
87
+ performance.now() + positive(options.timeoutMs ?? fallback, "timeoutMs");
88
+ this.interval = positive(options.pollIntervalMs ?? 500, "pollIntervalMs");
89
+ }
90
+ remaining() {
91
+ checkCancelled(this.options.signal, this.sessionId);
92
+ const left = this.deadline - performance.now();
93
+ if (left <= 0)
94
+ this.expired();
95
+ return left;
96
+ }
97
+ expired() {
98
+ throw new WaitTimeoutError(`${this.label} exceeded its total deadline.`, {
99
+ code: "WAIT_TIMEOUT",
100
+ sessionId: this.sessionId,
101
+ });
102
+ }
103
+ async request(call) {
104
+ const left = this.remaining();
105
+ try {
106
+ return await call({ ...this.options, timeoutMs: left });
107
+ }
108
+ catch (error) {
109
+ checkCancelled(this.options.signal, this.sessionId);
110
+ if (performance.now() >= this.deadline ||
111
+ (error instanceof RequestTimeoutError &&
112
+ error.code === "OPERATION_TIMEOUT"))
113
+ this.expired();
114
+ throw error;
115
+ }
116
+ }
117
+ async pause() {
118
+ await sleep(Math.min(this.interval, this.remaining()), this.options.signal, this.sessionId);
119
+ }
120
+ }
121
+ export class Connections {
122
+ transport;
123
+ constructor(transport) {
124
+ this.transport = transport;
125
+ }
126
+ create(sessionId, params, options = {}) {
127
+ return this.transport.request({
128
+ method: "POST",
129
+ path: `${path(sessionId)}/connections`,
130
+ sessionId,
131
+ json: params,
132
+ replayable: false,
133
+ uncertain: "connection.create",
134
+ parse: parseGrant,
135
+ }, options);
136
+ }
137
+ }
138
+ export class Handoffs {
139
+ transport;
140
+ sessions;
141
+ constructor(transport, sessions) {
142
+ this.transport = transport;
143
+ this.sessions = sessions;
144
+ }
145
+ create(sessionId, params = {}, options = {}) {
146
+ return this.transport.request({
147
+ method: "POST",
148
+ path: `${path(sessionId)}/handoffs`,
149
+ sessionId,
150
+ json: params,
151
+ key: operationKey(options.idempotencyKey),
152
+ replayable: true,
153
+ uncertain: "handoff.create",
154
+ parse: parseHandoff,
155
+ }, options);
156
+ }
157
+ get(sessionId, handoffId, options = {}) {
158
+ return this.transport.request({
159
+ method: "GET",
160
+ path: `${path(sessionId)}/handoffs/${segment(handoffId)}`,
161
+ sessionId,
162
+ replayable: true,
163
+ parse: parseHandoff,
164
+ }, options);
165
+ }
166
+ complete(sessionId, handoffId, options = {}) {
167
+ return this.transport.request({
168
+ method: "POST",
169
+ path: `${path(sessionId)}/handoffs/${segment(handoffId)}/complete`,
170
+ sessionId,
171
+ replayable: true,
172
+ parse: parseHandoff,
173
+ }, options);
174
+ }
175
+ async waitUntilCompleted(sessionId, handoffId, options = {}) {
176
+ const budget = new WaitBudget(options, sessionId, "Waiting for handoff completion", 300_000);
177
+ for await (const session of this.sessions.watch(sessionId, {
178
+ ...options,
179
+ timeoutMs: budget.remaining(),
180
+ })) {
181
+ const handoff = await budget.request((opts) => this.get(sessionId, handoffId, opts));
182
+ if (handoff.status === "cancelled")
183
+ throw new HandoffCancelledError("The handoff was cancelled.", {
184
+ code: "HANDOFF_CANCELLED",
185
+ sessionId,
186
+ });
187
+ if (session.status !== "ready")
188
+ ended(session);
189
+ if (handoff.status === "completed")
190
+ return handoff;
191
+ }
192
+ return budget.expired();
193
+ }
194
+ }
195
+ function multipart(params) {
196
+ if (!params.filename || /[\r\n\0\\/]/.test(params.filename))
197
+ throw new ConfigurationError("Upload filename must be a plain filename without control characters.", { code: "INVALID_FILENAME" });
198
+ const type = params.contentType ??
199
+ ((params.file instanceof Blob && params.file.type) ||
200
+ "application/octet-stream");
201
+ if (/[\r\n\0]/.test(type))
202
+ throw new ConfigurationError("Upload contentType cannot contain control characters.", { code: "INVALID_UPLOAD" });
203
+ const boundary = `tabsail-${crypto.randomUUID()}`;
204
+ const encoder = new TextEncoder();
205
+ const prefix = encoder.encode(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${params.filename.replace(/"/g, "%22")}"\r\nContent-Type: ${type}\r\n\r\n`);
206
+ const suffix = encoder.encode(`\r\n--${boundary}--\r\n`);
207
+ const knownSize = params.file instanceof Blob
208
+ ? params.file.size
209
+ : params.file instanceof Uint8Array
210
+ ? params.file.byteLength
211
+ : params.sizeBytes;
212
+ if (params.sizeBytes !== undefined &&
213
+ (!Number.isSafeInteger(params.sizeBytes) ||
214
+ params.sizeBytes < 0 ||
215
+ (knownSize !== undefined && params.sizeBytes !== knownSize)))
216
+ throw new ConfigurationError("sizeBytes must match the upload content length.", { code: "INVALID_UPLOAD" });
217
+ async function* bytes() {
218
+ yield prefix;
219
+ let sent = 0;
220
+ const source = params.file instanceof Blob ? params.file.stream() : params.file;
221
+ if (source instanceof Uint8Array) {
222
+ sent = source.byteLength;
223
+ yield source;
224
+ }
225
+ else {
226
+ const iterator = source instanceof ReadableStream ? streamIterator(source) : source;
227
+ for await (const part of iterator) {
228
+ if (!(part instanceof Uint8Array))
229
+ throw new ConfigurationError("Upload streams must yield Uint8Array chunks.", { code: "INVALID_UPLOAD" });
230
+ sent += part.byteLength;
231
+ if (knownSize !== undefined && sent > knownSize)
232
+ throw new ConfigurationError("Upload exceeded its declared sizeBytes.", { code: "INVALID_UPLOAD" });
233
+ yield part;
234
+ }
235
+ }
236
+ if (knownSize !== undefined && sent !== knownSize)
237
+ throw new ConfigurationError("Upload length differs from sizeBytes.", {
238
+ code: "INVALID_UPLOAD",
239
+ });
240
+ yield suffix;
241
+ }
242
+ const iterator = bytes();
243
+ const body = new ReadableStream({
244
+ async pull(controller) {
245
+ try {
246
+ const item = await iterator.next();
247
+ if (item.done)
248
+ controller.close();
249
+ else
250
+ controller.enqueue(item.value);
251
+ }
252
+ catch (error) {
253
+ controller.error(error);
254
+ }
255
+ },
256
+ async cancel() {
257
+ await iterator.return(undefined);
258
+ },
259
+ }, { highWaterMark: 0 });
260
+ return {
261
+ body,
262
+ headers: {
263
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
264
+ ...(knownSize === undefined
265
+ ? {}
266
+ : {
267
+ "Content-Length": String(prefix.byteLength + knownSize + suffix.byteLength),
268
+ }),
269
+ },
270
+ };
271
+ }
272
+ async function* streamIterator(stream) {
273
+ const reader = stream.getReader();
274
+ let completed = false;
275
+ try {
276
+ while (true) {
277
+ const item = await reader.read();
278
+ if (item.done) {
279
+ completed = true;
280
+ return;
281
+ }
282
+ yield item.value;
283
+ }
284
+ }
285
+ finally {
286
+ if (!completed)
287
+ await reader.cancel();
288
+ reader.releaseLock();
289
+ }
290
+ }
291
+ export class Files {
292
+ transport;
293
+ constructor(transport) {
294
+ this.transport = transport;
295
+ }
296
+ upload(sessionId, params, options = {}) {
297
+ checkCancelled(options.signal, sessionId);
298
+ const upload = multipart(params);
299
+ return this.transport.request({
300
+ method: "POST",
301
+ path: `${path(sessionId)}/files`,
302
+ sessionId,
303
+ ...upload,
304
+ replayable: false,
305
+ uncertain: "file.upload",
306
+ parse: parseFile,
307
+ }, options);
308
+ }
309
+ list(sessionId, params = {}, options = {}) {
310
+ return this.transport.request({
311
+ method: "GET",
312
+ path: `${path(sessionId)}/files${query(params)}`,
313
+ sessionId,
314
+ replayable: true,
315
+ parse: parsePage(parseFile),
316
+ }, options);
317
+ }
318
+ get(sessionId, fileId, options = {}) {
319
+ return this.transport.request({
320
+ method: "GET",
321
+ path: `${path(sessionId)}/files/${segment(fileId)}`,
322
+ sessionId,
323
+ replayable: true,
324
+ parse: parseFile,
325
+ }, options);
326
+ }
327
+ download(sessionId, fileId, options = {}) {
328
+ return this.transport.request({
329
+ method: "GET",
330
+ path: `${path(sessionId)}/files/${segment(fileId)}/content`,
331
+ sessionId,
332
+ replayable: true,
333
+ streaming: true,
334
+ }, options);
335
+ }
336
+ delete(sessionId, fileId, options = {}) {
337
+ return this.transport.request({
338
+ method: "DELETE",
339
+ path: `${path(sessionId)}/files/${segment(fileId)}`,
340
+ sessionId,
341
+ replayable: true,
342
+ }, options);
343
+ }
344
+ }
345
+ export class Sessions {
346
+ transport;
347
+ connections;
348
+ handoffs;
349
+ files;
350
+ constructor(transport) {
351
+ this.transport = transport;
352
+ this.connections = new Connections(transport);
353
+ this.handoffs = new Handoffs(transport, this);
354
+ this.files = new Files(transport);
355
+ }
356
+ /** Latest complete state snapshots, including an initial state and reconnect resynchronization. */
357
+ watch(sessionId, options = {}) {
358
+ return sessionUpdates(this.transport, path(sessionId), sessionId, options);
359
+ }
360
+ create(params = {}, options = {}) {
361
+ return this.transport.request({
362
+ method: "POST",
363
+ path: "browser-sessions",
364
+ json: params,
365
+ key: operationKey(options.idempotencyKey),
366
+ replayable: true,
367
+ uncertain: "session.create",
368
+ parse: parseSession,
369
+ }, options);
370
+ }
371
+ get(sessionId, options = {}) {
372
+ return this.transport.request({
373
+ method: "GET",
374
+ path: path(sessionId),
375
+ sessionId,
376
+ replayable: true,
377
+ parse: parseSession,
378
+ }, options);
379
+ }
380
+ list(params = {}, options = {}) {
381
+ return this.transport.request({
382
+ method: "GET",
383
+ path: `browser-sessions${query(params)}`,
384
+ replayable: true,
385
+ parse: parsePage(parseSession),
386
+ }, options);
387
+ }
388
+ update(sessionId, params, options = {}) {
389
+ return this.transport.request({
390
+ method: "PATCH",
391
+ path: path(sessionId),
392
+ sessionId,
393
+ json: params,
394
+ replayable: true,
395
+ parse: parseSession,
396
+ }, options);
397
+ }
398
+ touch(sessionId, options = {}) {
399
+ return this.transport.request({
400
+ method: "POST",
401
+ path: `${path(sessionId)}/touch`,
402
+ sessionId,
403
+ replayable: true,
404
+ parse: parseSession,
405
+ }, options);
406
+ }
407
+ close(sessionId, options = {}) {
408
+ return this.transport.request({
409
+ method: "POST",
410
+ path: `${path(sessionId)}/close`,
411
+ sessionId,
412
+ replayable: true,
413
+ parse: parseSession,
414
+ }, options);
415
+ }
416
+ async waitUntilReady(sessionId, options = {}) {
417
+ const budget = new WaitBudget(options, sessionId, "Waiting for session readiness", 60_000);
418
+ for await (const session of this.watch(sessionId, {
419
+ ...options,
420
+ timeoutMs: budget.remaining(),
421
+ })) {
422
+ if (session.status === "ready")
423
+ return session;
424
+ if (session.status !== "starting")
425
+ ended(session);
426
+ }
427
+ return budget.expired();
428
+ }
429
+ async waitUntilReleased(sessionId, options = {}) {
430
+ const budget = new WaitBudget(options, sessionId, "Waiting for session release", 30_000);
431
+ for await (const session of this.watch(sessionId, {
432
+ ...options,
433
+ timeoutMs: budget.remaining(),
434
+ })) {
435
+ if (session.releasedAt !== null)
436
+ return session;
437
+ }
438
+ return budget.expired();
439
+ }
440
+ async waitForHandoff(sessionId, options) {
441
+ const after = options.after;
442
+ if (!after ||
443
+ typeof after.controlEpoch !== "string" ||
444
+ !/^(0|[1-9]\d*)$/.test(after.controlEpoch) ||
445
+ !(after.latestHandoffId === null ||
446
+ typeof after.latestHandoffId === "string"))
447
+ throw new ConfigurationError("after must be a stable handoffCheckpoint.", { code: "INVALID_CHECKPOINT", sessionId });
448
+ const epoch = BigInt(after.controlEpoch);
449
+ const budget = new WaitBudget(options, sessionId, "Waiting for handoff and automation control", 300_000);
450
+ for await (const session of this.watch(sessionId, {
451
+ ...options,
452
+ timeoutMs: budget.remaining(),
453
+ })) {
454
+ if (session.status !== "ready")
455
+ ended(session);
456
+ const handoff = session.latestHandoff;
457
+ if (BigInt(session.controlEpoch) > epoch &&
458
+ handoff &&
459
+ handoff.id !== after.latestHandoffId) {
460
+ if (handoff.status === "cancelled")
461
+ throw new HandoffCancelledError("The handoff was cancelled.", {
462
+ code: "HANDOFF_CANCELLED",
463
+ sessionId,
464
+ });
465
+ if (handoff.status === "completed" &&
466
+ session.controller === "automation")
467
+ return session;
468
+ }
469
+ }
470
+ return budget.expired();
471
+ }
472
+ }
@@ -0,0 +1,44 @@
1
+ import { ResultUnknownError, TabSailError } from "./errors.js";
2
+ import type { RequestOptions, TabSailOptions } from "./types.js";
3
+ export declare function positive(value: number, name: string): number;
4
+ export declare function retries(value: number): number;
5
+ export declare function operationKey(value?: string): string;
6
+ export declare function checkCancelled(signal?: AbortSignal, sessionId?: string): void;
7
+ /** Retry-After is a floor; jitter must still spread callers when it is present. */
8
+ export declare function retryDelay(attempt: number, retryAfterMs?: number, maximumBackoffMs?: number, random?: number): number;
9
+ export declare function sleep(milliseconds: number, signal?: AbortSignal, sessionId?: string): Promise<void>;
10
+ type RequestSpec<T> = {
11
+ method: "GET" | "POST" | "PATCH" | "DELETE";
12
+ path: string;
13
+ sessionId?: string;
14
+ json?: unknown;
15
+ body?: BodyInit;
16
+ headers?: Record<string, string>;
17
+ key?: string;
18
+ replayable: boolean;
19
+ uncertain?: ResultUnknownError["operation"];
20
+ parse?: (value: unknown) => T;
21
+ streaming?: boolean;
22
+ };
23
+ /** A streaming download. Consume its body or explicitly close it. */
24
+ export declare class FileDownload implements AsyncIterable<Uint8Array>, AsyncDisposable {
25
+ #private;
26
+ readonly body: ReadableStream<Uint8Array>;
27
+ readonly headers: Headers;
28
+ readonly status: number;
29
+ constructor(response: Response, finish: () => void, abort: () => void, failure: (error: unknown) => TabSailError);
30
+ close(): Promise<void>;
31
+ [Symbol.asyncDispose](): Promise<void>;
32
+ [Symbol.asyncIterator](): AsyncGenerator<Uint8Array<ArrayBufferLike>, void, unknown>;
33
+ }
34
+ export declare class Transport {
35
+ #private;
36
+ readonly requestTimeoutMs: number;
37
+ readonly maxRetries: number;
38
+ constructor(options: TabSailOptions);
39
+ safe(error: unknown, sessionId?: string): TabSailError;
40
+ request<T>(spec: RequestSpec<T>, options?: RequestOptions): Promise<T>;
41
+ private sanitize;
42
+ private httpError;
43
+ }
44
+ export {};