@modelprofile.com/browser-runtime 5.2.0 → 5.3.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.
@@ -105,6 +105,7 @@ interface INormalizedRuntimeOptions {
105
105
  frameAcknowledgementTimeoutMs: number;
106
106
  screencast: INormalizedScreencastOptions;
107
107
  video: Required<plugins.smartpuppeteer.ILiveVideoOptions>;
108
+ devTools: boolean;
108
109
  egress: NonNullable<IBrowserRuntimeOptions['egress']>;
109
110
  ownership?: IBrowserRuntimeTestingOptions['ownership'];
110
111
  beforeLeasePublication?: IBrowserRuntimeTestingOptions['beforeLeasePublication'];
@@ -144,10 +145,25 @@ interface ILeaseRecord {
144
145
  videoPeerId: string;
145
146
  videoPeerMayExist: boolean;
146
147
  frameCaptureUsed: boolean;
148
+ devTools?: IDevToolsRecord;
147
149
  heldKeys: Map<string, plugins.smartpuppeteer.ILiveBrowserKeyInput>;
148
150
  heldButtons: Map<string, plugins.smartpuppeteer.ILiveBrowserMouseInput>;
149
151
  }
150
152
 
153
+ interface IDevToolsRecord {
154
+ authority: TBrowserRuntimeLeaseAuthority;
155
+ session: ILiveBrowserSessionLike;
156
+ controller: AbortController;
157
+ connection?: plugins.smartpuppeteer.ILiveBrowserDevToolsConnection;
158
+ opening?: Promise<plugins.smartpuppeteer.ILiveBrowserDevToolsConnection>;
159
+ closePromise?: Promise<void>;
160
+ pending: Set<Promise<void>>;
161
+ commands: number;
162
+ interrupts: number;
163
+ closed: boolean;
164
+ removeAbortListener(): void;
165
+ }
166
+
151
167
  export interface IBrowserRuntimeLeaseRenewalOptions {
152
168
  signal?: AbortSignal;
153
169
  /** Lifetime from successful reauthorization; defaults to the runtime capability lifetime. */
@@ -318,6 +334,9 @@ export class BrowserRuntime {
318
334
  if (options.beforeOperation !== undefined && typeof options.beforeOperation !== 'function') {
319
335
  throw new BrowserRuntimeError('INVALID_INPUT', 'beforeOperation must be a function');
320
336
  }
337
+ if (options.devTools !== undefined && typeof options.devTools !== 'boolean') {
338
+ throw new BrowserRuntimeError('INVALID_INPUT', 'devTools must be a boolean');
339
+ }
321
340
  const testingOptions = browserRuntimeTesting in options
322
341
  && options[browserRuntimeTesting] === true
323
342
  ? options as IBrowserRuntimeTestingOptions
@@ -570,6 +589,7 @@ export class BrowserRuntime {
570
589
  ),
571
590
  screencast,
572
591
  video: plugins.smartpuppeteer.normalizeLiveVideoOptions(options.video ?? {}),
592
+ devTools: options.devTools ?? false,
573
593
  egress: options.egress ?? {},
574
594
  ownership: testingOptions?.ownership,
575
595
  beforeLeasePublication: testingOptions?.beforeLeasePublication,
@@ -1374,6 +1394,148 @@ export class BrowserRuntime {
1374
1394
  });
1375
1395
  }
1376
1396
 
1397
+ /** @internal */
1398
+ public async leaseOpenDevTools(lease: ILeaseRecord, options: plugins.smartpuppeteer.ILiveBrowserDevToolsOptions,
1399
+ operationOptions: IBrowserRuntimeOperationOptions = {}): Promise<plugins.smartpuppeteer.ILiveBrowserDevToolsConnection> {
1400
+ this.requireHuman(lease);
1401
+ if (!this.options.devTools) throw new BrowserRuntimeError('CAPABILITY_INVALID', 'DevTools is not enabled.');
1402
+ if (!options || typeof options.onMessage !== 'function' || typeof options.onClose !== 'function') {
1403
+ throw new BrowserRuntimeError('INVALID_INPUT');
1404
+ }
1405
+ const tabId = validateBoundedString(options.tabId, 'tabId', 1, 128);
1406
+ let created: IDevToolsRecord | undefined;
1407
+ try {
1408
+ return await this.runOperation(lease, 'openDevTools', 'devtools', operationOptions, async (signal, session) => {
1409
+ if (!session.openDevTools) throw new BrowserRuntimeError('CAPABILITY_INVALID', 'The browser adapter does not support DevTools.');
1410
+ if (lease.devTools) await this.closeLeaseDevTools(lease, lease.devTools);
1411
+ const controller = new AbortController();
1412
+ const record: IDevToolsRecord = {
1413
+ authority: this.createLeaseAuthority(lease), session, controller,
1414
+ pending: new Set(), commands: 0, interrupts: 0, closed: false,
1415
+ removeAbortListener: () => lease.controller.signal.removeEventListener('abort', abort),
1416
+ };
1417
+ const abort = (): void => { this.trackCleanup(this.closeLeaseDevTools(lease, record)); };
1418
+ lease.controller.signal.addEventListener('abort', abort, { once: true });
1419
+ created = record;
1420
+ lease.devTools = record;
1421
+ record.opening = session.openDevTools({ tabId,
1422
+ onMessage: async message => {
1423
+ this.requireDevToolsAuthority(lease, record);
1424
+ await options.onMessage(message);
1425
+ this.requireDevToolsAuthority(lease, record);
1426
+ },
1427
+ onClose: reason => {
1428
+ record.closed = true;
1429
+ controller.abort(new BrowserRuntimeError('ABORTED'));
1430
+ record.removeAbortListener();
1431
+ options.onClose(reason);
1432
+ },
1433
+ }, { signal });
1434
+ const connection = await record.opening;
1435
+ record.connection = connection;
1436
+ try {
1437
+ signal.throwIfAborted();
1438
+ this.requireDevToolsAuthority(lease, record);
1439
+ } catch (error) { await this.closeLeaseDevTools(lease, record); throw error; }
1440
+ return {
1441
+ tabId: connection.tabId,
1442
+ browserVersion: connection.browserVersion,
1443
+ send: message => this.sendLeaseDevTools(lease, record, message),
1444
+ close: () => this.closeLeaseDevTools(lease, record),
1445
+ };
1446
+ });
1447
+ } catch (error) {
1448
+ if (created) await this.closeLeaseDevTools(lease, created);
1449
+ throw error;
1450
+ }
1451
+ }
1452
+
1453
+ private requireDevToolsAuthority(lease: ILeaseRecord, record: IDevToolsRecord): void {
1454
+ this.requireHuman(lease);
1455
+ if (record.closed || record.controller.signal.aborted || lease.devTools !== record
1456
+ || lease.slot.session !== record.session || !this.isLeaseAuthorityCurrent(lease, record.authority)) {
1457
+ throw new BrowserRuntimeError('CAPABILITY_REVOKED');
1458
+ }
1459
+ }
1460
+
1461
+ private async sendLeaseDevTools(lease: ILeaseRecord, record: IDevToolsRecord, message: string): Promise<void> {
1462
+ this.requireDevToolsAuthority(lease, record);
1463
+ const limits = plugins.smartpuppeteer.liveBrowserDevToolsLimits;
1464
+ if (typeof message !== 'string' || plugins.Buffer.byteLength(message) > limits.commandBytes) {
1465
+ throw new BrowserRuntimeError('INVALID_INPUT');
1466
+ }
1467
+ let command: { method?: unknown };
1468
+ try { command = JSON.parse(message); } catch { throw new BrowserRuntimeError('INVALID_INPUT'); }
1469
+ if (!command || typeof command.method !== 'string' || !/^[A-Za-z]+\.[A-Za-z]+$/.test(command.method)) {
1470
+ throw new BrowserRuntimeError('INVALID_INPUT');
1471
+ }
1472
+ const action = command.method;
1473
+ const interrupt = limits.interruptMethods.includes(action);
1474
+ if (interrupt ? record.interrupts >= limits.interruptCommands : record.commands >= limits.commands) {
1475
+ throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1476
+ }
1477
+ if (interrupt) record.interrupts++; else record.commands++;
1478
+ const startedAt = Date.now();
1479
+ const operation: IOperationRecord = {
1480
+ operationId: randomId(), lease, controller: record.controller, action, startedAt,
1481
+ session: record.session, incarnationGeneration: record.authority.incarnationGeneration,
1482
+ promise: Promise.resolve(),
1483
+ };
1484
+ const identity = this.createOperationIdentity(lease, operation, action, 'devtools', startedAt);
1485
+ const task = (async () => {
1486
+ let failed = false;
1487
+ let failure: unknown;
1488
+ try {
1489
+ await this.runBeforeOperation(identity, record.controller.signal);
1490
+ this.requireDevToolsAuthority(lease, record);
1491
+ await record.connection!.send(message);
1492
+ this.requireDevToolsAuthority(lease, record);
1493
+ } catch (error) { failed = true; failure = error; }
1494
+ finally {
1495
+ try {
1496
+ await this.emitAudit(Object.freeze({ ...identity, phase: failed ? 'failed' : 'completed',
1497
+ finishedAt: Date.now(), ...(failed ? { errorCode: this.operationErrorCode(failure) } : {}) }));
1498
+ } finally {
1499
+ if (interrupt) record.interrupts--; else record.commands--;
1500
+ }
1501
+ }
1502
+ if (failed) throw failure;
1503
+ })();
1504
+ record.pending.add(task);
1505
+ void task.then(() => record.pending.delete(task), () => record.pending.delete(task));
1506
+ return task;
1507
+ }
1508
+
1509
+ private closeLeaseDevTools(lease: ILeaseRecord, record: IDevToolsRecord): Promise<void> {
1510
+ if (record.closePromise) return record.closePromise;
1511
+ record.closed = true;
1512
+ record.controller.abort(new BrowserRuntimeError('ABORTED'));
1513
+ record.removeAbortListener();
1514
+ const closing = (async () => {
1515
+ try {
1516
+ const detached = await waitBounded((async () => {
1517
+ // Opening may still be completing when lease revocation arrives.
1518
+ await Promise.resolve();
1519
+ const connection = record.connection ?? await record.opening?.catch(() => undefined);
1520
+ await connection?.close();
1521
+ })(), this.options.quiescenceTimeoutMs + 3500);
1522
+ if (!detached.settled) throw new BrowserRuntimeError('TIMEOUT');
1523
+ const pending = await waitBounded(Promise.allSettled([...record.pending]),
1524
+ this.options.quiescenceTimeoutMs + this.options.auditTimeoutMs + this.options.beforeOperationTimeoutMs);
1525
+ if (!pending.settled) throw new BrowserRuntimeError('TIMEOUT');
1526
+ } catch (error) {
1527
+ await this.terminateRegisteredResourceInternal(lease.slot, false, {
1528
+ session: record.session, incarnationGeneration: record.authority.incarnationGeneration,
1529
+ });
1530
+ throw error;
1531
+ } finally {
1532
+ if (lease.devTools === record) lease.devTools = undefined;
1533
+ }
1534
+ })();
1535
+ record.closePromise = closing;
1536
+ return closing;
1537
+ }
1538
+
1377
1539
  /** @internal */
1378
1540
  public async leaseAnswerVideoPeer(lease: ILeaseRecord, negotiationId: string,
1379
1541
  description: plugins.smartpuppeteer.ILiveVideoDescription, options: IBrowserRuntimeOperationOptions = {}): Promise<void> {
@@ -2013,6 +2175,7 @@ export class BrowserRuntime {
2013
2175
  usePipe: true,
2014
2176
  allowEvaluation: true,
2015
2177
  video: this.options.video,
2178
+ allowDevTools: this.options.devTools,
2016
2179
  screencast: {
2017
2180
  enabled: false, maxOutstandingFrames: this.options.maxOutstandingFrames,
2018
2181
  quality: this.options.screencast.quality,
@@ -3477,6 +3640,7 @@ export class BrowserRuntime {
3477
3640
  release();
3478
3641
  }
3479
3642
  await this.closeFrameSubscriptionIfLease(lease);
3643
+ if (lease.devTools) await this.closeLeaseDevTools(lease, lease.devTools);
3480
3644
  if (operation) {
3481
3645
  const settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
3482
3646
  if (!settled.settled) {
@@ -4381,6 +4545,11 @@ export class BrowserRuntimeLease {
4381
4545
  return this.runtime.leaseOpenVideoPeer(this.record, options);
4382
4546
  }
4383
4547
 
4548
+ public openDevTools(options: plugins.smartpuppeteer.ILiveBrowserDevToolsOptions,
4549
+ operationOptions?: IBrowserRuntimeOperationOptions): Promise<plugins.smartpuppeteer.ILiveBrowserDevToolsConnection> {
4550
+ return this.runtime.leaseOpenDevTools(this.record, options, operationOptions);
4551
+ }
4552
+
4384
4553
  public answerVideoPeer(negotiationId: string, description: plugins.smartpuppeteer.ILiveVideoDescription,
4385
4554
  options?: IBrowserRuntimeOperationOptions): Promise<void> {
4386
4555
  return this.runtime.leaseAnswerVideoPeer(this.record, negotiationId, description, options);
@@ -1133,20 +1133,23 @@ export class BrowserRuntimeOwnership {
1133
1133
  }
1134
1134
 
1135
1135
  private async listOwnedProcessIds(): Promise<number[]> {
1136
- let entries: plugins.fs.Dirent[];
1136
+ let entries: string[];
1137
1137
  try {
1138
- entries = await plugins.fsPromises.readdir(this.procRoot, { withFileTypes: true });
1138
+ // /proc can report DT_UNKNOWN. Node's eager Dirent conversion then
1139
+ // lstat()s every entry and rejects the entire scan if one process exits.
1140
+ // Enumerate names; the per-PID inspection below owns disappearance checks.
1141
+ entries = await plugins.fsPromises.readdir(this.procRoot);
1139
1142
  } catch {
1140
1143
  throw new BrowserRuntimeError('FENCED', 'process inspection is unavailable');
1141
1144
  }
1142
1145
  const processIds: number[] = [];
1143
1146
  let indeterminate = false;
1144
1147
  for (const entry of entries) {
1145
- if (!/^[1-9][0-9]{0,9}$/u.test(entry.name)) continue;
1146
- const pid = Number(entry.name);
1148
+ if (!/^[1-9][0-9]{0,9}$/u.test(entry)) continue;
1149
+ const pid = Number(entry);
1147
1150
  try {
1148
1151
  const stat = await plugins.fsPromises.lstat(
1149
- plugins.path.join(this.procRoot, entry.name),
1152
+ plugins.path.join(this.procRoot, entry),
1150
1153
  { bigint: true },
1151
1154
  );
1152
1155
  if (!stat.isDirectory() || stat.isSymbolicLink()) {
package/ts/interfaces.ts CHANGED
@@ -134,6 +134,7 @@ export type TBrowserRuntimeOperationClassification =
134
134
  | 'raw-input'
135
135
  | 'frame-stream'
136
136
  | 'video-peer'
137
+ | 'devtools'
137
138
  | 'viewport'
138
139
  | 'navigation'
139
140
  | 'tab'
@@ -160,6 +161,8 @@ export type TBrowserRuntimeAuditEvent = TBrowserRuntimeOperationIdentity & {
160
161
  };
161
162
 
162
163
  export interface ILiveBrowserSessionLike {
164
+ openDevTools?(options: plugins.smartpuppeteer.ILiveBrowserDevToolsOptions,
165
+ operationOptions?: plugins.smartpuppeteer.ILiveBrowserOperationOptions): Promise<plugins.smartpuppeteer.ILiveBrowserDevToolsConnection>;
163
166
  start(options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions): Promise<void>;
164
167
  stop(): Promise<void>;
165
168
  terminate(
@@ -300,6 +303,8 @@ export interface IBrowserRuntimeOptions {
300
303
  screencast?: IBrowserRuntimeScreencastOptions;
301
304
  /** Trusted host configuration; defaults to direct ICE and automatic GPU support. */
302
305
  video?: plugins.smartpuppeteer.ILiveVideoOptions;
306
+ /** Enables selected-tab DevTools for human leases only. Disabled by default. */
307
+ devTools?: boolean;
303
308
  egress?: Omit<IBrowserEgressProxyOptions, 'projectId' | 'browserResourceId'>;
304
309
  artifacts?: Omit<IBrowserArtifactStoreOptions, 'rootDirectory'>;
305
310
  }