browserscale-ts 1.4.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.
- package/README.md +34 -3
- package/dist/auth-session.d.ts +37 -0
- package/dist/auth-session.js +1 -0
- package/dist/browser.d.ts +5 -1
- package/dist/browser.js +5 -0
- package/dist/browserscale.browser.js +1662 -42
- package/dist/client.d.ts +350 -7
- package/dist/client.js +637 -8
- package/dist/dom-mirror.d.ts +271 -0
- package/dist/dom-mirror.js +613 -0
- package/dist/gen/wrc_pb.d.ts +1490 -168
- package/dist/gen/wrc_pb.js +234 -39
- package/dist/index.d.ts +32 -1
- package/dist/index.js +61 -0
- package/dist/internal/convert.d.ts +11 -2
- package/dist/internal/convert.js +84 -1
- package/dist/network-capture.d.ts +84 -0
- package/dist/network-capture.js +107 -0
- package/dist/scripts.d.ts +205 -0
- package/dist/scripts.js +234 -0
- package/dist/types.d.ts +157 -0
- package/dist/ws-transport.d.ts +15 -1
- package/dist/ws-transport.js +155 -10
- package/package.json +1 -1
package/dist/scripts.js
ADDED
|
@@ -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
|
package/dist/ws-transport.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/ws-transport.js
CHANGED
|
@@ -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 ===
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
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
|
-
|
|
94
|
-
|
|
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.
|
|
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",
|