@modelprofile.com/browser-runtime 1.0.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.
Files changed (51) hide show
  1. package/.smartconfig.json +34 -0
  2. package/changelog.md +11 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/actions.d.ts +3 -0
  6. package/dist_ts/actions.js +215 -0
  7. package/dist_ts/classes.artifactstore.d.ts +38 -0
  8. package/dist_ts/classes.artifactstore.js +344 -0
  9. package/dist_ts/classes.egressproxy.d.ts +67 -0
  10. package/dist_ts/classes.egressproxy.js +830 -0
  11. package/dist_ts/classes.flexprovider.d.ts +9 -0
  12. package/dist_ts/classes.flexprovider.js +117 -0
  13. package/dist_ts/classes.framed.d.ts +52 -0
  14. package/dist_ts/classes.framed.js +557 -0
  15. package/dist_ts/classes.runtime.d.ts +202 -0
  16. package/dist_ts/classes.runtime.js +1667 -0
  17. package/dist_ts/confinement.d.ts +2 -0
  18. package/dist_ts/confinement.js +63 -0
  19. package/dist_ts/errors.d.ts +7 -0
  20. package/dist_ts/errors.js +40 -0
  21. package/dist_ts/index.d.ts +11 -0
  22. package/dist_ts/index.js +9 -0
  23. package/dist_ts/interfaces.d.ts +287 -0
  24. package/dist_ts/interfaces.js +2 -0
  25. package/dist_ts/internal.testing.d.ts +9 -0
  26. package/dist_ts/internal.testing.js +2 -0
  27. package/dist_ts/mcp.d.ts +4 -0
  28. package/dist_ts/mcp.js +196 -0
  29. package/dist_ts/plugins.d.ts +20 -0
  30. package/dist_ts/plugins.js +24 -0
  31. package/dist_ts/utils.d.ts +25 -0
  32. package/dist_ts/utils.js +143 -0
  33. package/license.md +21 -0
  34. package/package.json +59 -0
  35. package/readme.hints.md +35 -0
  36. package/readme.md +181 -0
  37. package/ts/00_commitinfo_data.ts +8 -0
  38. package/ts/actions.ts +241 -0
  39. package/ts/classes.artifactstore.ts +432 -0
  40. package/ts/classes.egressproxy.ts +1005 -0
  41. package/ts/classes.flexprovider.ts +134 -0
  42. package/ts/classes.framed.ts +649 -0
  43. package/ts/classes.runtime.ts +2135 -0
  44. package/ts/confinement.ts +90 -0
  45. package/ts/errors.ts +63 -0
  46. package/ts/index.ts +52 -0
  47. package/ts/interfaces.ts +375 -0
  48. package/ts/internal.testing.ts +18 -0
  49. package/ts/mcp.ts +230 -0
  50. package/ts/plugins.ts +28 -0
  51. package/ts/utils.ts +188 -0
@@ -0,0 +1,649 @@
1
+ import * as plugins from './plugins.js';
2
+ import { validateAgentAction, validateAgentActionResult } from './actions.js';
3
+ import type { BrowserRuntime, BrowserRuntimeLease } from './classes.runtime.js';
4
+ import { BrowserRuntimeError, type TBrowserRuntimeErrorCode } from './errors.js';
5
+ import type {
6
+ IAttachTrustedFramedPeerOptions,
7
+ IBrowserRuntimeFramedClientOptions,
8
+ IBrowserRuntimeOperationOptions,
9
+ TBrowserAgentAction,
10
+ TBrowserAgentActionResult,
11
+ } from './interfaces.js';
12
+ import {
13
+ assertJsonWithoutBytes,
14
+ randomId,
15
+ validateBoundedString,
16
+ validateExactKeys,
17
+ validateInteger,
18
+ waitBounded,
19
+ } from './utils.js';
20
+
21
+ interface IFramedSuccessResponse {
22
+ version: 1;
23
+ id: string;
24
+ ok: true;
25
+ result: unknown;
26
+ }
27
+
28
+ interface IFramedErrorResponse {
29
+ version: 1;
30
+ id: string;
31
+ ok: false;
32
+ error: {
33
+ code: TBrowserRuntimeErrorCode;
34
+ message: string;
35
+ };
36
+ }
37
+
38
+ type TFramedResponse = IFramedSuccessResponse | IFramedErrorResponse;
39
+
40
+ interface IPendingClientRequest {
41
+ resolve(value: unknown): void;
42
+ reject(error: unknown): void;
43
+ timer: ReturnType<typeof setTimeout>;
44
+ }
45
+
46
+ const protocolVersion = 1;
47
+ const hardMaxFrameBytes = 256 * 1024;
48
+ const hardMaxPendingRequests = 16;
49
+ const hardRequestTimeoutMs = 60_000;
50
+
51
+ class JsonFramedChannel {
52
+ private buffer = plugins.Buffer.alloc(0);
53
+ private writeTail = Promise.resolve();
54
+ private started = false;
55
+ private closed = false;
56
+ private queuedBytes = 0;
57
+ private readonly onDataBound = (chunk: plugins.Buffer | string): void => {
58
+ this.onData(chunk);
59
+ };
60
+ private readonly onCloseBound = (): void => {
61
+ this.closeFromStream();
62
+ };
63
+
64
+ constructor(
65
+ private readonly readable: plugins.stream.Readable,
66
+ private readonly writable: plugins.stream.Writable,
67
+ private readonly maxFrameBytes: number,
68
+ private readonly writeTimeoutMs: number,
69
+ private readonly onMessage: (message: unknown) => void,
70
+ private readonly onClose: () => void,
71
+ ) {}
72
+
73
+ public start(): void {
74
+ if (this.started || this.closed) return;
75
+ this.started = true;
76
+ this.readable.on('data', this.onDataBound);
77
+ this.readable.once('end', this.onCloseBound);
78
+ this.readable.once('close', this.onCloseBound);
79
+ this.readable.once('error', this.onCloseBound);
80
+ this.writable.once('close', this.onCloseBound);
81
+ this.writable.once('error', this.onCloseBound);
82
+ }
83
+
84
+ public send(message: unknown): Promise<void> {
85
+ if (this.closed) return Promise.reject(new BrowserRuntimeError('PROTOCOL_ERROR'));
86
+ assertJsonWithoutBytes(message, this.maxFrameBytes);
87
+ const payload = plugins.Buffer.from(JSON.stringify(message), 'utf8');
88
+ if (payload.byteLength > this.maxFrameBytes) {
89
+ return Promise.reject(new BrowserRuntimeError('PROTOCOL_ERROR'));
90
+ }
91
+ const header = plugins.Buffer.allocUnsafe(4);
92
+ header.writeUInt32BE(payload.byteLength, 0);
93
+ const frame = plugins.Buffer.concat([header, payload]);
94
+ if (this.queuedBytes + frame.byteLength > this.maxFrameBytes * hardMaxPendingRequests) {
95
+ return Promise.reject(new BrowserRuntimeError('BUSY'));
96
+ }
97
+ this.queuedBytes += frame.byteLength;
98
+ const write = this.writeTail.then(() => this.write(frame)).finally(() => {
99
+ this.queuedBytes -= frame.byteLength;
100
+ });
101
+ this.writeTail = write.catch(() => undefined);
102
+ return write;
103
+ }
104
+
105
+ public async close(): Promise<void> {
106
+ if (this.closed) return;
107
+ this.closed = true;
108
+ this.removeListeners();
109
+ if (typeof this.readable.destroy === 'function') this.readable.destroy();
110
+ if (typeof this.writable.destroy === 'function') this.writable.destroy();
111
+ await waitBounded(this.writeTail, this.writeTimeoutMs).catch(() => undefined);
112
+ this.onClose();
113
+ }
114
+
115
+ private onData(chunkArg: plugins.Buffer | string): void {
116
+ if (this.closed) return;
117
+ const chunk = typeof chunkArg === 'string'
118
+ ? plugins.Buffer.from(chunkArg, 'utf8')
119
+ : chunkArg;
120
+ this.buffer = plugins.Buffer.concat([this.buffer, chunk]);
121
+ while (this.buffer.byteLength >= 4) {
122
+ const length = this.buffer.readUInt32BE(0);
123
+ if (length < 2 || length > this.maxFrameBytes) {
124
+ void this.close();
125
+ return;
126
+ }
127
+ if (this.buffer.byteLength < length + 4) return;
128
+ const payload = this.buffer.subarray(4, length + 4);
129
+ this.buffer = this.buffer.subarray(length + 4);
130
+ try {
131
+ const message = JSON.parse(payload.toString('utf8')) as unknown;
132
+ assertJsonWithoutBytes(message, this.maxFrameBytes);
133
+ this.onMessage(message);
134
+ } catch {
135
+ void this.close();
136
+ return;
137
+ }
138
+ }
139
+ if (this.buffer.byteLength > this.maxFrameBytes + 4) void this.close();
140
+ }
141
+
142
+ private write(frame: plugins.Buffer): Promise<void> {
143
+ return new Promise<void>((resolve, reject) => {
144
+ if (this.closed || !this.writable.writable) {
145
+ reject(new BrowserRuntimeError('PROTOCOL_ERROR'));
146
+ return;
147
+ }
148
+ let settled = false;
149
+ const timer = setTimeout(() => {
150
+ finish(new BrowserRuntimeError('TIMEOUT'));
151
+ this.closeFromStream();
152
+ }, this.writeTimeoutMs);
153
+ const cleanup = (): void => {
154
+ clearTimeout(timer);
155
+ this.writable.off('error', onError);
156
+ this.writable.off('close', onClose);
157
+ };
158
+ const finish = (error?: Error): void => {
159
+ if (settled) return;
160
+ settled = true;
161
+ cleanup();
162
+ if (error) reject(error);
163
+ else resolve();
164
+ };
165
+ const onError = (error: Error): void => finish(error);
166
+ const onClose = (): void => finish(new BrowserRuntimeError('PROTOCOL_ERROR'));
167
+ this.writable.once('error', onError);
168
+ this.writable.once('close', onClose);
169
+ this.writable.write(frame, (error?: Error | null) => finish(error ?? undefined));
170
+ });
171
+ }
172
+
173
+ private closeFromStream(): void {
174
+ if (this.closed) return;
175
+ this.closed = true;
176
+ this.removeListeners();
177
+ if (typeof this.readable.destroy === 'function') this.readable.destroy();
178
+ if (typeof this.writable.destroy === 'function') this.writable.destroy();
179
+ this.onClose();
180
+ }
181
+
182
+ private removeListeners(): void {
183
+ this.readable.off('data', this.onDataBound);
184
+ this.readable.off('end', this.onCloseBound);
185
+ this.readable.off('close', this.onCloseBound);
186
+ this.readable.off('error', this.onCloseBound);
187
+ this.writable.off('close', this.onCloseBound);
188
+ this.writable.off('error', this.onCloseBound);
189
+ }
190
+ }
191
+
192
+ export class BrowserRuntimeFramedServerPeer {
193
+ private readonly trustedPeerId: string;
194
+ private readonly trustedScopeId: string;
195
+ private readonly trustedSessionId: string;
196
+ private readonly channel: JsonFramedChannel;
197
+ private readonly activeRequests = new Map<string, AbortController>();
198
+ private lease?: BrowserRuntimeLease;
199
+ private started = false;
200
+ private closePromise?: Promise<void>;
201
+ private closing = false;
202
+ private readonly dispatches = new Set<Promise<void>>();
203
+
204
+ constructor(
205
+ private readonly runtime: BrowserRuntime,
206
+ options: IAttachTrustedFramedPeerOptions,
207
+ private readonly onClosed: () => void = () => undefined,
208
+ ) {
209
+ this.trustedPeerId = validateBoundedString(options.peerId, 'peerId', 1, 128);
210
+ this.trustedScopeId = validateBoundedString(options.scopeId, 'scopeId', 1, 128);
211
+ this.trustedSessionId = validateBoundedString(options.sessionId, 'sessionId', 1, 128);
212
+ if (
213
+ !(options.readable instanceof plugins.stream.Readable)
214
+ || !(options.writable instanceof plugins.stream.Writable)
215
+ ) {
216
+ throw new BrowserRuntimeError('INVALID_INPUT');
217
+ }
218
+ this.channel = new JsonFramedChannel(
219
+ options.readable,
220
+ options.writable,
221
+ hardMaxFrameBytes,
222
+ hardRequestTimeoutMs,
223
+ (message) => this.handleMessage(message),
224
+ () => { void this.closeFromStream().catch(() => undefined); },
225
+ );
226
+ }
227
+
228
+ public get peerId(): string {
229
+ return this.trustedPeerId;
230
+ }
231
+
232
+ public start(): void {
233
+ if (this.started) return;
234
+ this.started = true;
235
+ this.channel.start();
236
+ }
237
+
238
+ public close(): Promise<void> {
239
+ if (this.closePromise) return this.closePromise;
240
+ this.closePromise = this.closeInternal().catch((error) => {
241
+ this.closePromise = undefined;
242
+ throw error;
243
+ });
244
+ return this.closePromise;
245
+ }
246
+
247
+ private handleMessage(message: unknown): void {
248
+ if (this.closing) return;
249
+ let id = 'invalid';
250
+ try {
251
+ const request = validateExactKeys(
252
+ message,
253
+ ['version', 'id', 'method', 'capabilityToken', 'action', 'targetRequestId'],
254
+ 'framed request',
255
+ );
256
+ if (request.version !== protocolVersion) throw new BrowserRuntimeError('PROTOCOL_ERROR');
257
+ id = validateBoundedString(request.id, 'id', 1, 128);
258
+ const method = validateBoundedString(request.method, 'method', 1, 32);
259
+ if (
260
+ this.activeRequests.size >= hardMaxPendingRequests
261
+ && (method !== 'cancel' || this.activeRequests.size >= hardMaxPendingRequests + 4)
262
+ ) {
263
+ throw new BrowserRuntimeError('BUSY');
264
+ }
265
+ const controller = new AbortController();
266
+ if (this.activeRequests.has(id)) throw new BrowserRuntimeError('PROTOCOL_ERROR');
267
+ this.activeRequests.set(id, controller);
268
+ const dispatch = this.dispatchRequest(id, method, request, controller).finally(() => {
269
+ this.activeRequests.delete(id);
270
+ this.dispatches.delete(dispatch);
271
+ });
272
+ this.dispatches.add(dispatch);
273
+ } catch (error) {
274
+ void this.sendError(id, error);
275
+ }
276
+ }
277
+
278
+ private async dispatchRequest(
279
+ id: string,
280
+ method: string,
281
+ request: Record<string, unknown>,
282
+ controller: AbortController,
283
+ ): Promise<void> {
284
+ try {
285
+ let result: unknown;
286
+ if (method === 'acquire') {
287
+ validateExactKeys(request, ['version', 'id', 'method', 'capabilityToken'], 'acquire request');
288
+ if (this.lease) throw new BrowserRuntimeError('BUSY');
289
+ const capabilityToken = validateBoundedString(
290
+ request.capabilityToken,
291
+ 'capabilityToken',
292
+ 16,
293
+ 512,
294
+ );
295
+ this.lease = await this.runtime.acquireLease({
296
+ capabilityToken,
297
+ peerId: this.trustedPeerId,
298
+ expectedRole: 'agent',
299
+ expectedSource: 'flex',
300
+ scopeId: this.trustedScopeId,
301
+ sessionId: this.trustedSessionId,
302
+ signal: controller.signal,
303
+ });
304
+ if (this.closing || controller.signal.aborted) {
305
+ await this.lease.release();
306
+ this.lease = undefined;
307
+ throw new BrowserRuntimeError('ABORTED');
308
+ }
309
+ result = {
310
+ leaseId: this.lease.leaseId,
311
+ role: this.lease.role,
312
+ scopeId: this.trustedScopeId,
313
+ sessionId: this.trustedSessionId,
314
+ };
315
+ } else if (method === 'execute') {
316
+ validateExactKeys(request, ['version', 'id', 'method', 'action'], 'execute request');
317
+ const lease = this.requireLease();
318
+ const action = validateAgentAction(request.action);
319
+ result = await lease.executeAgentAction(action, { signal: controller.signal });
320
+ } else if (method === 'cancel') {
321
+ validateExactKeys(
322
+ request,
323
+ ['version', 'id', 'method', 'targetRequestId'],
324
+ 'cancel request',
325
+ );
326
+ const targetRequestId = validateBoundedString(
327
+ request.targetRequestId,
328
+ 'targetRequestId',
329
+ 1,
330
+ 128,
331
+ );
332
+ const target = this.activeRequests.get(targetRequestId);
333
+ target?.abort(new BrowserRuntimeError('ABORTED'));
334
+ const cancelled = Boolean(target);
335
+ result = { cancelled };
336
+ } else if (method === 'release') {
337
+ validateExactKeys(request, ['version', 'id', 'method'], 'release request');
338
+ const lease = this.requireLease();
339
+ await lease.release();
340
+ this.lease = undefined;
341
+ result = { released: true };
342
+ } else {
343
+ throw new BrowserRuntimeError('PROTOCOL_ERROR');
344
+ }
345
+ assertJsonWithoutBytes(result, hardMaxFrameBytes);
346
+ await this.channel.send({ version: protocolVersion, id, ok: true, result });
347
+ } catch (error) {
348
+ await this.sendError(id, error);
349
+ }
350
+ }
351
+
352
+ private requireLease(): BrowserRuntimeLease {
353
+ if (!this.lease) throw new BrowserRuntimeError('CAPABILITY_INVALID');
354
+ return this.lease;
355
+ }
356
+
357
+ private async sendError(id: string, error: unknown): Promise<void> {
358
+ const runtimeError = error instanceof BrowserRuntimeError
359
+ ? error
360
+ : new BrowserRuntimeError('PROTOCOL_ERROR');
361
+ await this.channel.send({
362
+ version: protocolVersion,
363
+ id,
364
+ ok: false,
365
+ error: { code: runtimeError.code, message: runtimeError.publicMessage },
366
+ } satisfies IFramedErrorResponse).catch(() => undefined);
367
+ }
368
+
369
+ private async closeInternal(): Promise<void> {
370
+ this.closing = true;
371
+ for (const controller of this.activeRequests.values()) {
372
+ controller.abort(new BrowserRuntimeError('ABORTED'));
373
+ }
374
+ const dispatchResult = await waitBounded(
375
+ Promise.allSettled([...this.dispatches]).then(() => undefined),
376
+ hardRequestTimeoutMs,
377
+ );
378
+ if (!dispatchResult.settled) {
379
+ await this.runtime.disconnectPeer(this.trustedPeerId);
380
+ }
381
+ await this.lease?.release();
382
+ this.lease = undefined;
383
+ await this.runtime.disconnectPeer(this.trustedPeerId);
384
+ await this.channel.close();
385
+ this.onClosed();
386
+ }
387
+
388
+ private async closeFromStream(): Promise<void> {
389
+ if (this.closePromise) return;
390
+ this.closePromise = (async () => {
391
+ this.closing = true;
392
+ for (const controller of this.activeRequests.values()) {
393
+ controller.abort(new BrowserRuntimeError('ABORTED'));
394
+ }
395
+ await waitBounded(
396
+ Promise.allSettled([...this.dispatches]).then(() => undefined),
397
+ hardRequestTimeoutMs,
398
+ );
399
+ await this.lease?.release();
400
+ this.lease = undefined;
401
+ await this.runtime.disconnectPeer(this.trustedPeerId);
402
+ this.onClosed();
403
+ })().catch((error) => {
404
+ this.closePromise = undefined;
405
+ throw error;
406
+ });
407
+ await this.closePromise;
408
+ }
409
+ }
410
+
411
+ export class BrowserRuntimeFramedClient {
412
+ private readonly channel: JsonFramedChannel;
413
+ private readonly maxPendingRequests: number;
414
+ private readonly requestTimeoutMs: number;
415
+ private readonly pending = new Map<string, IPendingClientRequest>();
416
+ private acquired = false;
417
+ private started = false;
418
+ private closePromise?: Promise<void>;
419
+ private readonly scopeIdValue: string;
420
+ private readonly sessionIdValue: string;
421
+
422
+ constructor(options: IBrowserRuntimeFramedClientOptions) {
423
+ this.scopeIdValue = validateBoundedString(options.scopeId, 'scopeId', 1, 128);
424
+ this.sessionIdValue = validateBoundedString(options.sessionId, 'sessionId', 1, 128);
425
+ if (
426
+ !(options.readable instanceof plugins.stream.Readable)
427
+ || !(options.writable instanceof plugins.stream.Writable)
428
+ ) {
429
+ throw new BrowserRuntimeError('INVALID_INPUT');
430
+ }
431
+ const maxFrameBytes = options.maxFrameBytes === undefined
432
+ ? hardMaxFrameBytes
433
+ : validateInteger(options.maxFrameBytes, 'maxFrameBytes', 1024, hardMaxFrameBytes);
434
+ this.maxPendingRequests = options.maxPendingRequests === undefined
435
+ ? hardMaxPendingRequests
436
+ : validateInteger(
437
+ options.maxPendingRequests,
438
+ 'maxPendingRequests',
439
+ 1,
440
+ hardMaxPendingRequests,
441
+ );
442
+ this.requestTimeoutMs = options.requestTimeoutMs === undefined
443
+ ? hardRequestTimeoutMs
444
+ : validateInteger(options.requestTimeoutMs, 'requestTimeoutMs', 100, hardRequestTimeoutMs);
445
+ this.channel = new JsonFramedChannel(
446
+ options.readable,
447
+ options.writable,
448
+ maxFrameBytes,
449
+ this.requestTimeoutMs,
450
+ (message) => this.handleResponse(message),
451
+ () => this.handleClose(),
452
+ );
453
+ }
454
+
455
+ public start(): void {
456
+ if (this.started) return;
457
+ this.started = true;
458
+ this.channel.start();
459
+ }
460
+
461
+ public get scopeId(): string {
462
+ return this.scopeIdValue;
463
+ }
464
+
465
+ public get sessionId(): string {
466
+ return this.sessionIdValue;
467
+ }
468
+
469
+ public async acquire(capabilityTokenArg: string, signal?: AbortSignal): Promise<void> {
470
+ if (this.acquired) throw new BrowserRuntimeError('BUSY');
471
+ signal?.throwIfAborted();
472
+ const capabilityToken = validateBoundedString(
473
+ capabilityTokenArg,
474
+ 'capabilityToken',
475
+ 16,
476
+ 512,
477
+ );
478
+ this.start();
479
+ const id = randomId(12);
480
+ const acquisition = this.requestWithId(id, 'acquire', { capabilityToken });
481
+ const onAbort = (): void => {
482
+ void this.request('cancel', { targetRequestId: id }).catch(() => undefined);
483
+ };
484
+ signal?.addEventListener('abort', onAbort, { once: true });
485
+ try {
486
+ await acquisition;
487
+ signal?.throwIfAborted();
488
+ this.acquired = true;
489
+ } finally {
490
+ signal?.removeEventListener('abort', onAbort);
491
+ }
492
+ }
493
+
494
+ public async executeAgentAction(
495
+ actionArg: TBrowserAgentAction,
496
+ options: IBrowserRuntimeOperationOptions = {},
497
+ ): Promise<TBrowserAgentActionResult> {
498
+ if (!this.acquired) throw new BrowserRuntimeError('CAPABILITY_INVALID');
499
+ options.signal?.throwIfAborted();
500
+ const action = validateAgentAction(actionArg);
501
+ const id = randomId(12);
502
+ const requestPromise = this.requestWithId(id, 'execute', { action });
503
+ const onAbort = (): void => {
504
+ void this.request('cancel', { targetRequestId: id }).catch(() => undefined);
505
+ };
506
+ options.signal?.addEventListener('abort', onAbort, { once: true });
507
+ try {
508
+ const result = await requestPromise;
509
+ assertJsonWithoutBytes(result, hardMaxFrameBytes);
510
+ return validateAgentActionResult(result);
511
+ } finally {
512
+ options.signal?.removeEventListener('abort', onAbort);
513
+ }
514
+ }
515
+
516
+ public asToolBrowserContext(): plugins.smartagent.IToolBrowserContext {
517
+ return {
518
+ execute: async (input, options) => {
519
+ const record = validateExactKeys(
520
+ input,
521
+ ['action', 'url', 'selector', 'text', 'timeoutMs'],
522
+ 'browser tool input',
523
+ );
524
+ const actionName = validateBoundedString(record.action ?? 'snapshot', 'action', 1, 32);
525
+ let action: TBrowserAgentAction;
526
+ if (actionName === 'navigate') {
527
+ action = { action: 'navigate', url: String(record.url ?? '') };
528
+ } else if (actionName === 'snapshot') {
529
+ action = { action: 'snapshot' };
530
+ } else if (actionName === 'screenshot') {
531
+ action = { action: 'screenshot' };
532
+ } else if (actionName === 'click') {
533
+ action = { action: 'click', selector: String(record.selector ?? '') };
534
+ } else if (actionName === 'fill') {
535
+ action = {
536
+ action: 'fill',
537
+ selector: String(record.selector ?? ''),
538
+ text: String(record.text ?? ''),
539
+ };
540
+ } else if (actionName === 'press') {
541
+ action = {
542
+ action: 'press',
543
+ selector: String(record.selector ?? ''),
544
+ key: String(record.text ?? ''),
545
+ };
546
+ } else {
547
+ throw new BrowserRuntimeError('INVALID_INPUT');
548
+ }
549
+ if (record.timeoutMs !== undefined) {
550
+ const timeoutMs = validateInteger(record.timeoutMs, 'timeoutMs', 100, 120_000);
551
+ if (
552
+ action.action === 'navigate'
553
+ || action.action === 'click'
554
+ || action.action === 'fill'
555
+ || action.action === 'press'
556
+ ) action.timeoutMs = timeoutMs;
557
+ }
558
+ return this.executeAgentAction(action, { signal: options?.abortSignal });
559
+ },
560
+ };
561
+ }
562
+
563
+ public async release(): Promise<void> {
564
+ if (!this.acquired) return;
565
+ await this.request('release', {});
566
+ this.acquired = false;
567
+ }
568
+
569
+ public close(): Promise<void> {
570
+ if (this.closePromise) return this.closePromise;
571
+ this.closePromise = this.closeInternal();
572
+ return this.closePromise;
573
+ }
574
+
575
+ private request(method: string, fields: Record<string, unknown>): Promise<unknown> {
576
+ return this.requestWithId(randomId(12), method, fields);
577
+ }
578
+
579
+ private async requestWithId(
580
+ id: string,
581
+ method: string,
582
+ fields: Record<string, unknown>,
583
+ ): Promise<unknown> {
584
+ this.start();
585
+ if (this.pending.size >= this.maxPendingRequests) throw new BrowserRuntimeError('BUSY');
586
+ const responsePromise = new Promise<unknown>((resolve, reject) => {
587
+ const timer = setTimeout(() => {
588
+ this.pending.delete(id);
589
+ reject(new BrowserRuntimeError('TIMEOUT'));
590
+ }, this.requestTimeoutMs);
591
+ this.pending.set(id, { resolve, reject, timer });
592
+ });
593
+ void responsePromise.catch(() => undefined);
594
+ try {
595
+ await this.channel.send({ version: protocolVersion, id, method, ...fields });
596
+ } catch (error) {
597
+ const pending = this.pending.get(id);
598
+ if (pending) {
599
+ clearTimeout(pending.timer);
600
+ this.pending.delete(id);
601
+ pending.reject(error);
602
+ }
603
+ }
604
+ return responsePromise;
605
+ }
606
+
607
+ private handleResponse(message: unknown): void {
608
+ try {
609
+ const response = validateExactKeys(message, ['version', 'id', 'ok', 'result', 'error'], 'response');
610
+ if (response.version !== protocolVersion || typeof response.ok !== 'boolean') {
611
+ throw new BrowserRuntimeError('PROTOCOL_ERROR');
612
+ }
613
+ const id = validateBoundedString(response.id, 'id', 1, 128);
614
+ const pending = this.pending.get(id);
615
+ if (!pending) throw new BrowserRuntimeError('PROTOCOL_ERROR');
616
+ clearTimeout(pending.timer);
617
+ this.pending.delete(id);
618
+ if (response.ok) {
619
+ validateExactKeys(message, ['version', 'id', 'ok', 'result'], 'success response');
620
+ assertJsonWithoutBytes(response.result, hardMaxFrameBytes);
621
+ pending.resolve(response.result);
622
+ } else {
623
+ validateExactKeys(message, ['version', 'id', 'ok', 'error'], 'error response');
624
+ const error = validateExactKeys(response.error, ['code', 'message'], 'protocol error');
625
+ const code = validateBoundedString(error.code, 'error code', 1, 64) as TBrowserRuntimeErrorCode;
626
+ pending.reject(new BrowserRuntimeError(code));
627
+ }
628
+ } catch {
629
+ void this.close();
630
+ }
631
+ }
632
+
633
+ private handleClose(): void {
634
+ for (const pending of this.pending.values()) {
635
+ clearTimeout(pending.timer);
636
+ pending.reject(new BrowserRuntimeError('PROTOCOL_ERROR'));
637
+ }
638
+ this.pending.clear();
639
+ this.acquired = false;
640
+ }
641
+
642
+ private async closeInternal(): Promise<void> {
643
+ let releaseError: unknown;
644
+ await this.release().catch((error) => { releaseError = error; });
645
+ await this.channel.close();
646
+ this.handleClose();
647
+ if (releaseError) throw releaseError;
648
+ }
649
+ }