browserscale-ts 1.5.0 → 1.7.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,234 @@
1
+ import { BrowserScaleError } from "./errors.js";
2
+ /** @internal Converts one log entry off the wire. */
3
+ export function scriptLogEntryFromProto(e) {
4
+ return {
5
+ level: e.level,
6
+ message: e.message,
7
+ timestamp: new Date(Number(e.timestamp)),
8
+ };
9
+ }
10
+ /**
11
+ * @internal Converts one stream item, or returns null for an event carrying
12
+ * neither variant — which a newer server could send and an older client should
13
+ * ignore rather than report as an empty log line.
14
+ */
15
+ export function scriptEventFromProto(event) {
16
+ const payload = event.event;
17
+ if (payload.case === "log") {
18
+ if (!payload.value.line)
19
+ return null;
20
+ return {
21
+ runId: payload.value.runId,
22
+ log: scriptLogEntryFromProto(payload.value.line),
23
+ };
24
+ }
25
+ if (payload.case === "finished") {
26
+ return {
27
+ runId: payload.value.runId,
28
+ finished: {
29
+ success: payload.value.success,
30
+ result: payload.value.result,
31
+ stopped: payload.value.stopped,
32
+ },
33
+ };
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * ScriptRun is a script running in the background, returned by
39
+ * {@link CloudBrowser.startScript}. Its output is delivered to the handler
40
+ * passed there; this handle exists to await the outcome and to cancel the run.
41
+ */
42
+ export class ScriptRun {
43
+ /** @internal Constructed by CloudBrowser; not part of the public API. */
44
+ constructor(init) {
45
+ this.detached = false;
46
+ this.failure = null;
47
+ this.droppedCount = 0;
48
+ this.result = null;
49
+ this.id = init.runId;
50
+ this.abort = init.abort;
51
+ this.cancel = init.cancel;
52
+ this.endedPromise = new Promise((resolve) => {
53
+ this.ended = resolve;
54
+ });
55
+ this.finished = this.pump(init.stream, init.onEvent);
56
+ }
57
+ /**
58
+ * The id the browser gave this run. Pass it to
59
+ * {@link CloudBrowser.stopScripts} to cancel the run from elsewhere, or to
60
+ * {@link CloudBrowser.followScript} to watch it from another tab.
61
+ */
62
+ get runId() {
63
+ return this.id;
64
+ }
65
+ /**
66
+ * Reads the stream and calls the handler for each event belonging to this
67
+ * run. Filtering happens here rather than server-side because the
68
+ * subscription had to exist before the run id did.
69
+ *
70
+ * It never rejects: a failure is recorded for {@link ScriptRun.error}
71
+ * instead, so nothing surfaces as an unhandled rejection.
72
+ */
73
+ async pump(stream, onEvent) {
74
+ try {
75
+ for await (const message of stream) {
76
+ if (message.dropped > 0n) {
77
+ this.droppedCount = Number(message.dropped);
78
+ }
79
+ const event = scriptEventFromProto(message);
80
+ if (!event || event.runId !== this.id)
81
+ continue;
82
+ onEvent(event);
83
+ if (event.finished) {
84
+ this.result = event.finished;
85
+ this.ended();
86
+ return;
87
+ }
88
+ }
89
+ }
90
+ catch (err) {
91
+ // Cancelling is how stop() and detach() end the stream, so the resulting
92
+ // error is expected rather than a failure worth reporting.
93
+ if (!this.detached) {
94
+ this.failure = err instanceof Error ? err : new Error(String(err));
95
+ }
96
+ }
97
+ finally {
98
+ this.ended();
99
+ }
100
+ }
101
+ /**
102
+ * Why the stream ended: null while it is still open, and after a clean stop
103
+ * or the session ending normally.
104
+ */
105
+ get error() {
106
+ return this.failure;
107
+ }
108
+ /**
109
+ * How many events the server discarded because this reader fell behind.
110
+ * Anything above zero means the log has holes: make the handler cheaper, or
111
+ * have the script print less.
112
+ */
113
+ get dropped() {
114
+ return this.droppedCount;
115
+ }
116
+ /**
117
+ * Resolves once the run ends, with how it ended.
118
+ *
119
+ * A script that threw is an outcome, not an error: it resolves with
120
+ * `success: false`. It rejects when the run's fate is unknown — the stream
121
+ * broke or the session died before the script finished.
122
+ *
123
+ * @returns how the script ended
124
+ *
125
+ * @throws UNKNOWN_ERROR - the outcome could not be observed
126
+ */
127
+ async wait() {
128
+ await this.endedPromise;
129
+ if (this.result)
130
+ return this.result;
131
+ if (this.failure)
132
+ throw this.failure;
133
+ throw new BrowserScaleError("browserscale.ScriptRun.wait: stream ended before the run did");
134
+ }
135
+ /**
136
+ * Cancels the run and detaches this reader. Idempotent, and safe to call from
137
+ * a `finally`.
138
+ *
139
+ * A script that is executing is interrupted; one parked on an await unwinds
140
+ * at its next operation in the page. Either way the handler sees a `finished`
141
+ * with `stopped` set, unless the local reader is torn down first.
142
+ *
143
+ * @throws UNKNOWN_ERROR - the run could not be cancelled server-side; the
144
+ * local reader is detached regardless
145
+ */
146
+ async stop() {
147
+ if (this.detached)
148
+ return;
149
+ this.detached = true;
150
+ try {
151
+ await this.cancel(this.id);
152
+ }
153
+ finally {
154
+ this.abort.abort();
155
+ await this.finished;
156
+ }
157
+ }
158
+ /**
159
+ * Stops reading this run's output without cancelling the run. The script
160
+ * keeps going with nobody watching, which is what makes a detached run
161
+ * outlive the page that started it.
162
+ */
163
+ async detach() {
164
+ if (this.detached)
165
+ return;
166
+ this.detached = true;
167
+ this.abort.abort();
168
+ await this.finished;
169
+ }
170
+ }
171
+ /**
172
+ * ScriptFollow is a read-only view of script output, returned by
173
+ * {@link CloudBrowser.followScript}.
174
+ */
175
+ export class ScriptFollow {
176
+ /** @internal Constructed by CloudBrowser; not part of the public API. */
177
+ constructor(init) {
178
+ this.stopped = false;
179
+ this.failure = null;
180
+ this.droppedCount = 0;
181
+ this.abort = init.abort;
182
+ this.finished = this.pump(init.stream, init.onEvent);
183
+ }
184
+ async pump(stream, onEvent) {
185
+ try {
186
+ for await (const message of stream) {
187
+ if (message.dropped > 0n) {
188
+ this.droppedCount = Number(message.dropped);
189
+ }
190
+ const event = scriptEventFromProto(message);
191
+ if (event)
192
+ onEvent(event);
193
+ }
194
+ }
195
+ catch (err) {
196
+ if (!this.stopped) {
197
+ this.failure = err instanceof Error ? err : new Error(String(err));
198
+ }
199
+ }
200
+ }
201
+ /**
202
+ * Why the subscription ended: null while it is still open and after a clean
203
+ * stop.
204
+ */
205
+ get error() {
206
+ return this.failure;
207
+ }
208
+ /** How many events the server discarded because this reader fell behind. */
209
+ get dropped() {
210
+ return this.droppedCount;
211
+ }
212
+ /**
213
+ * Resolves once the subscription ends — {@link ScriptFollow.stop}, a dead
214
+ * session or a transport failure.
215
+ *
216
+ * @throws the transport failure that ended the subscription, if any
217
+ */
218
+ async wait() {
219
+ await this.finished;
220
+ if (this.failure)
221
+ throw this.failure;
222
+ }
223
+ /**
224
+ * Ends the subscription. Idempotent, and safe to call from a `finally`. It
225
+ * never cancels a run: other readers, and the script itself, are unaffected.
226
+ */
227
+ async stop() {
228
+ if (this.stopped)
229
+ return;
230
+ this.stopped = true;
231
+ this.abort.abort();
232
+ await this.finished;
233
+ }
234
+ }
package/dist/types.d.ts CHANGED
@@ -52,6 +52,115 @@ export interface InterceptedResponse {
52
52
  headers: Header[];
53
53
  body: string;
54
54
  }
55
+ /**
56
+ * NetworkResourceType is the kind of load an exchange belongs to.
57
+ *
58
+ * Typed as a union with a `string` fallback so an exchange from a newer browser
59
+ * still carries its value through instead of failing to type. Note that
60
+ * `fetch()`, XMLHttpRequest and EventSource all report `"fetch"`: they are
61
+ * indistinguishable at the capture point.
62
+ */
63
+ export type NetworkResourceType = "document" | "subframe" | "script" | "stylesheet" | "image" | "font" | "media" | "fetch" | "worker" | "manifest" | "object" | "csp-report" | "other" | (string & {});
64
+ /**
65
+ * NetworkServedFrom says where an exchange's response came from.
66
+ * `"wrcStaticCache"` is browserscale's own static cache — see
67
+ * {@link CloudBrowser.setStaticPaths}.
68
+ */
69
+ export type NetworkServedFrom = "network" | "cache" | "serviceWorker" | "wrcStaticCache" | "wrcSynthetic" | (string & {});
70
+ /**
71
+ * NetworkExchange is one request together with the response it received, as
72
+ * reported by {@link CloudBrowser.captureNetwork}.
73
+ *
74
+ * A redirect chain arrives as one exchange per hop: the hops share `chainId` and
75
+ * count up `redirectIndex`, so a 302 and the request it points at are two
76
+ * exchanges, each with its own headers and status.
77
+ */
78
+ export interface NetworkExchange {
79
+ /** Unique per hop. */
80
+ requestId: string;
81
+ /** Shared by every hop of one redirect chain. */
82
+ chainId: string;
83
+ /** 0 for the original request, incremented once per redirect followed. */
84
+ redirectIndex: number;
85
+ /** The frame that issued the request; empty for worker traffic. */
86
+ frameId: string;
87
+ /**
88
+ * Whether that frame runs in its own process. Capture happens in the browser
89
+ * process, so cross-process iframes are included.
90
+ */
91
+ isOopif: boolean;
92
+ resourceType: NetworkResourceType;
93
+ method: string;
94
+ url: string;
95
+ /** The origin that started the request; empty when the browser itself did. */
96
+ initiatorUrl: string;
97
+ requestHeaders: Header[];
98
+ /**
99
+ * Whether requestHeaders are the bytes actually sent — Cookie, User-Agent and
100
+ * Sec-* included — rather than what the page asked for before the network
101
+ * stack filled in the rest.
102
+ */
103
+ requestHeadersAreWire: boolean;
104
+ /**
105
+ * Inline body only. File and streamed uploads set requestBodyTruncated
106
+ * instead of appearing here.
107
+ */
108
+ requestBody: Uint8Array;
109
+ requestBodyTruncated: boolean;
110
+ /** False when the request failed before any response arrived; see error. */
111
+ hasResponse: boolean;
112
+ statusCode: number;
113
+ statusText: string;
114
+ mimeType: string;
115
+ /** Negotiated ALPN protocol, e.g. "h2" or "http/1.1". */
116
+ protocol: string;
117
+ remoteAddress: string;
118
+ servedFrom: NetworkServedFrom;
119
+ responseHeaders: Header[];
120
+ responseHeadersAreWire: boolean;
121
+ /**
122
+ * Populated only when body capture was requested for this URL and applied;
123
+ * check responseBodyCaptured to tell an empty body from an uncaptured one.
124
+ * Binary content does not survive the browser boundary intact — see
125
+ * {@link NetworkCaptureOptions.bodies}.
126
+ */
127
+ responseBody: Uint8Array;
128
+ responseBodyTruncated: boolean;
129
+ responseBodyCaptured: boolean;
130
+ /** Bytes on the wire, not body size; 0 for a response served from cache. */
131
+ encodedDataLength: number;
132
+ /** Net error name (e.g. "net::ERR_ABORTED"), empty on success. */
133
+ error: string;
134
+ }
135
+ /** How much of a response body a network capture keeps. */
136
+ export type NetworkBodies = "none" | "text" | "all";
137
+ /**
138
+ * Configures {@link CloudBrowser.captureNetwork}.
139
+ *
140
+ * There is deliberately no byte-cap option: buffer sizes bound memory on a
141
+ * machine shared with other sessions, so the server owns them.
142
+ */
143
+ export interface NetworkCaptureOptions {
144
+ /**
145
+ * URL wildcards to capture; omit to capture every request the session makes.
146
+ * Prefix a pattern with "!" to exclude it, which is the short way to say
147
+ * "everything except this".
148
+ */
149
+ patterns?: string[];
150
+ /**
151
+ * Response-body capture. `"text"` keeps bodies whose MIME type is textual,
152
+ * `"all"` keeps every body — but binary payloads (images, fonts, video) do
153
+ * not cross the browser boundary intact, so prefer `"text"` unless you know
154
+ * the bodies are textual. Defaults to `"none"`, headers and status only.
155
+ */
156
+ bodies?: NetworkBodies;
157
+ /**
158
+ * Narrows body capture to a subset of the captured requests; omit to apply
159
+ * bodies to all of them. Use it to log every request but only keep the
160
+ * payloads you care about.
161
+ */
162
+ bodyPatterns?: string[];
163
+ }
55
164
  /**
56
165
  * WaitResult is the outcome of a {@link CloudBrowser.wait} /
57
166
  * {@link CloudBrowser.waitForAny} call: which condition matched (index, in
@@ -269,6 +378,34 @@ export interface RentResponse {
269
378
  acceptLanguage: string;
270
379
  fingerprint: string;
271
380
  }
381
+ /** BrowserInfo describes one running session, as `listBrowsers` reports it. */
382
+ export interface BrowserInfo {
383
+ sessionId: string;
384
+ /**
385
+ * The endpoint this session is driven from — the same one rent returned. It is
386
+ * what makes a listed id usable: pass it to `connectSession`.
387
+ */
388
+ grpcUrl: string;
389
+ /** Unix seconds the session was rented at. */
390
+ startTime: number;
391
+ /** The rental length in seconds; 0 means unlimited. */
392
+ rentDuration: number;
393
+ /**
394
+ * Seconds left on the rental, and undefined for an unlimited one — there is
395
+ * nothing to count down.
396
+ */
397
+ remainingSeconds?: number;
398
+ countryCode: string;
399
+ timezone: string;
400
+ proxyHost: string;
401
+ /** The address the session egresses from. */
402
+ publicIp: string;
403
+ /**
404
+ * The physical card the session renders on, and undefined on a
405
+ * software-rendered host.
406
+ */
407
+ gpuIndex?: number;
408
+ }
272
409
  /**
273
410
  * IceServer is one entry for a WebRTC `RTCPeerConnection`'s `iceServers`
274
411
  * config: a TURN (or STUN) URL plus the short-lived credentials to
@@ -282,6 +419,26 @@ export interface IceServer {
282
419
  /** Short-lived TURN REST credential (empty for plain STUN). */
283
420
  credential: string;
284
421
  }
422
+ /**
423
+ * StreamAnswer is what {@link CloudBrowser.startStream} replies with.
424
+ */
425
+ export interface StreamAnswer {
426
+ /** SDP answer to apply as your peer's remote description. */
427
+ answerSdp: string;
428
+ /**
429
+ * The page's viewport in CSS pixels — the coordinate space its input
430
+ * expects. The video may be displayed at any size, so map your pointer
431
+ * positions into this space before sending them. It comes back with the
432
+ * answer rather than from a separate {@link CloudBrowser.getPages} so it
433
+ * cannot race the stream or describe a different page, and the browser
434
+ * pushes `{"type":"viewport","width":W,"height":H}` on the reliable "input"
435
+ * data channel whenever it changes. Null against an older engine.
436
+ */
437
+ viewport: {
438
+ width: number;
439
+ height: number;
440
+ } | null;
441
+ }
285
442
  /**
286
443
  * ReactionInfo describes a still-pending reaction, as returned by
287
444
  * {@link CloudBrowser.listReactions}. One-shot reactions that have already
@@ -3,13 +3,27 @@ import type { DescMessage, DescMethodUnary, DescMethodStreaming, MessageInitShap
3
3
  export declare class WebSocketTransport implements Transport {
4
4
  private ws;
5
5
  private pending;
6
+ private streams;
6
7
  private queue;
7
8
  private connected;
8
9
  private url;
9
10
  constructor(url: string);
10
11
  private connect;
12
+ private dispatchStreamFrame;
11
13
  private send;
14
+ /** Builds a request frame: [4B id LE][2B method_len LE][method][payload]. */
15
+ private static frame;
12
16
  unary<I extends DescMessage, O extends DescMessage>(method: DescMethodUnary<I, O>, signal: AbortSignal | undefined, _timeoutMs: number | undefined, _header: HeadersInit | undefined, input: MessageInitShape<I>, _contextValues?: ContextValues): Promise<UnaryResponse<I, O>>;
13
- stream<I extends DescMessage, O extends DescMessage>(_method: DescMethodStreaming<I, O>, _signal: AbortSignal | undefined, _timeoutMs: number | undefined, _header: HeadersInit | undefined, _input: AsyncIterable<MessageInitShape<I>>, _contextValues?: ContextValues): Promise<StreamResponse<I, O>>;
17
+ /**
18
+ * Runs a server-streaming call. Client-streaming is not supported: the frame
19
+ * format carries exactly one request message, and no RPC needs more.
20
+ *
21
+ * Resolving means the server confirmed the subscription, so a caller may act
22
+ * on it — arm a network capture, say — without racing the first message.
23
+ */
24
+ stream<I extends DescMessage, O extends DescMessage>(method: DescMethodStreaming<I, O>, signal: AbortSignal | undefined, _timeoutMs: number | undefined, _header: HeadersInit | undefined, input: AsyncIterable<MessageInitShape<I>>, _contextValues?: ContextValues): Promise<StreamResponse<I, O>>;
25
+ private readStream;
26
+ /** Drops a stream locally and asks the server to stop producing. */
27
+ private cancelStream;
14
28
  close(): void;
15
29
  }
@@ -1,9 +1,35 @@
1
1
  import { create, toBinary, fromBinary } from "@bufbuild/protobuf";
2
2
  let nextId = 0;
3
+ /**
4
+ * Response frame status byte, mirroring the wsStatus constants in
5
+ * internal/bserver/ws_grpc_proxy.go.
6
+ */
7
+ const STATUS_OK = 0;
8
+ const STATUS_ERROR = 1;
9
+ const STATUS_STREAM_MSG = 2;
10
+ const STATUS_STREAM_END = 3;
11
+ const STATUS_STREAM_READY = 4;
12
+ /** Control frame method name; real gRPC methods always contain a "/". */
13
+ const CANCEL = "cancel";
14
+ function wake(stream) {
15
+ const pending = stream.wake;
16
+ stream.wake = null;
17
+ pending?.();
18
+ }
19
+ /**
20
+ * Fails a stream so its iterator stops instead of parking forever. Passing a
21
+ * null error ends it cleanly.
22
+ */
23
+ function endStream(stream, err) {
24
+ stream.done = true;
25
+ stream.error = err;
26
+ wake(stream);
27
+ }
3
28
  export class WebSocketTransport {
4
29
  constructor(url) {
5
30
  this.ws = null;
6
31
  this.pending = new Map();
32
+ this.streams = new Map();
7
33
  this.queue = [];
8
34
  this.connected = false;
9
35
  this.url = url;
@@ -27,11 +53,16 @@ export class WebSocketTransport {
27
53
  const id = view.getUint32(0, true);
28
54
  const status = buf[4];
29
55
  const payload = buf.slice(5);
56
+ const stream = this.streams.get(id);
57
+ if (stream) {
58
+ this.dispatchStreamFrame(id, stream, status, payload);
59
+ return;
60
+ }
30
61
  const call = this.pending.get(id);
31
62
  if (!call)
32
63
  return;
33
64
  this.pending.delete(id);
34
- if (status === 0) {
65
+ if (status === STATUS_OK) {
35
66
  call.resolve(payload);
36
67
  }
37
68
  else {
@@ -44,12 +75,48 @@ export class WebSocketTransport {
44
75
  call.reject(new Error("WebSocket closed"));
45
76
  }
46
77
  this.pending.clear();
78
+ // Streams cannot survive the socket: the reconnect below is a fresh
79
+ // connection the server knows nothing about. Callers that need to keep
80
+ // reading resubscribe — for a network capture the capture itself stays
81
+ // armed server-side, so only the gap is lost.
82
+ const closed = new Error("WebSocket closed");
83
+ for (const [, stream] of this.streams) {
84
+ stream.onReadyFailed?.(closed);
85
+ endStream(stream, closed);
86
+ }
87
+ this.streams.clear();
47
88
  setTimeout(() => this.connect(), 1000);
48
89
  };
49
90
  this.ws.onerror = () => {
50
91
  this.ws?.close();
51
92
  };
52
93
  }
94
+ dispatchStreamFrame(id, stream, status, payload) {
95
+ switch (status) {
96
+ case STATUS_STREAM_READY: {
97
+ const ready = stream.onReady;
98
+ stream.onReady = null;
99
+ stream.onReadyFailed = null;
100
+ ready?.();
101
+ return;
102
+ }
103
+ case STATUS_STREAM_MSG:
104
+ stream.queue.push(payload);
105
+ wake(stream);
106
+ return;
107
+ case STATUS_STREAM_END:
108
+ this.streams.delete(id);
109
+ endStream(stream, null);
110
+ return;
111
+ default: {
112
+ const err = new Error(new TextDecoder().decode(payload));
113
+ this.streams.delete(id);
114
+ stream.onReadyFailed?.(err);
115
+ endStream(stream, err);
116
+ return;
117
+ }
118
+ }
119
+ }
53
120
  send(data) {
54
121
  if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
55
122
  this.ws.send(data);
@@ -58,21 +125,25 @@ export class WebSocketTransport {
58
125
  this.queue.push(data);
59
126
  }
60
127
  }
61
- async unary(method, signal, _timeoutMs, _header, input, _contextValues) {
62
- const id = ++nextId;
63
- const methodName = `${method.parent.typeName}/${method.name}`;
64
- const inputMsg = create(method.input, input);
65
- const payload = toBinary(method.input, inputMsg);
66
- const methodBytes = new TextEncoder().encode(methodName);
128
+ /** Builds a request frame: [4B id LE][2B method_len LE][method][payload]. */
129
+ static frame(id, method, payload) {
130
+ const methodBytes = new TextEncoder().encode(method);
67
131
  const frame = new Uint8Array(6 + methodBytes.length + payload.length);
68
132
  const view = new DataView(frame.buffer);
69
133
  view.setUint32(0, id, true);
70
134
  view.setUint16(4, methodBytes.length, true);
71
135
  frame.set(methodBytes, 6);
72
136
  frame.set(payload, 6 + methodBytes.length);
137
+ return frame;
138
+ }
139
+ async unary(method, signal, _timeoutMs, _header, input, _contextValues) {
140
+ const id = ++nextId;
141
+ const methodName = `${method.parent.typeName}/${method.name}`;
142
+ const inputMsg = create(method.input, input);
143
+ const payload = toBinary(method.input, inputMsg);
73
144
  const responsePayload = await new Promise((resolve, reject) => {
74
145
  this.pending.set(id, { resolve, reject });
75
- this.send(frame);
146
+ this.send(WebSocketTransport.frame(id, methodName, payload));
76
147
  if (signal) {
77
148
  signal.addEventListener("abort", () => {
78
149
  this.pending.delete(id);
@@ -90,8 +161,82 @@ export class WebSocketTransport {
90
161
  message: output,
91
162
  };
92
163
  }
93
- async stream(_method, _signal, _timeoutMs, _header, _input, _contextValues) {
94
- throw new Error("Streaming not supported over WebSocket transport");
164
+ /**
165
+ * Runs a server-streaming call. Client-streaming is not supported: the frame
166
+ * format carries exactly one request message, and no RPC needs more.
167
+ *
168
+ * Resolving means the server confirmed the subscription, so a caller may act
169
+ * on it — arm a network capture, say — without racing the first message.
170
+ */
171
+ async stream(method, signal, _timeoutMs, _header, input, _contextValues) {
172
+ let request;
173
+ for await (const msg of input) {
174
+ request = msg;
175
+ break;
176
+ }
177
+ if (request === undefined) {
178
+ throw new Error("WebSocket transport: server streaming needs one request message");
179
+ }
180
+ const id = ++nextId;
181
+ const methodName = `${method.parent.typeName}/${method.name}`;
182
+ const payload = toBinary(method.input, create(method.input, request));
183
+ const stream = {
184
+ queue: [],
185
+ wake: null,
186
+ done: false,
187
+ error: null,
188
+ onReady: null,
189
+ onReadyFailed: null,
190
+ };
191
+ this.streams.set(id, stream);
192
+ const ready = new Promise((resolve, reject) => {
193
+ stream.onReady = resolve;
194
+ stream.onReadyFailed = reject;
195
+ });
196
+ this.send(WebSocketTransport.frame(id, methodName, payload));
197
+ signal?.addEventListener("abort", () => this.cancelStream(id), { once: true });
198
+ await ready;
199
+ return {
200
+ stream: true,
201
+ service: method.parent,
202
+ method,
203
+ header: new Headers(),
204
+ trailer: new Headers(),
205
+ message: this.readStream(method, id, stream),
206
+ };
207
+ }
208
+ async *readStream(method, id, stream) {
209
+ try {
210
+ for (;;) {
211
+ while (stream.queue.length === 0 && !stream.done) {
212
+ await new Promise((resolve) => {
213
+ stream.wake = resolve;
214
+ });
215
+ }
216
+ const buf = stream.queue.shift();
217
+ if (buf === undefined) {
218
+ // Queue drained; a failure only surfaces once nothing is left.
219
+ if (stream.error)
220
+ throw stream.error;
221
+ return;
222
+ }
223
+ yield fromBinary(method.output, buf);
224
+ }
225
+ }
226
+ finally {
227
+ // Reached on break/return/throw in the consumer too, so abandoning the
228
+ // loop stops the server rather than leaking the stream.
229
+ this.cancelStream(id);
230
+ }
231
+ }
232
+ /** Drops a stream locally and asks the server to stop producing. */
233
+ cancelStream(id) {
234
+ const stream = this.streams.get(id);
235
+ if (!stream)
236
+ return;
237
+ this.streams.delete(id);
238
+ endStream(stream, null);
239
+ this.send(WebSocketTransport.frame(id, CANCEL, new Uint8Array(0)));
95
240
  }
96
241
  close() {
97
242
  this.ws?.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browserscale-ts",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Official SDK for browserscale — undetected stealth browsers in the cloud. Rent real Chromium sessions with unique fingerprints, built-in proxies, captcha solving, human-like input and live video — scale web scraping and automation without running a single browser yourself.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",