@push.rocks/smartpuppeteer 2.7.0 → 2.8.1

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.
@@ -1,4 +1,7 @@
1
1
  import { getEnvAwareBrowserInstance } from './smartpuppeteer.classes.smartpuppeteer.js';
2
+ import { LiveBrowserVideo } from './classes.livevideo.js';
3
+ import { normalizeLiveVideoOptions, type ILiveVideoDescription, type ILiveVideoOffer,
4
+ type ILiveVideoStatistics } from './interfaces.livevideo.js';
2
5
  import {
3
6
  liveBrowserDefaultMaxOutstandingFrames,
4
7
  liveBrowserMaxOutstandingFrames,
@@ -504,6 +507,8 @@ export class LiveBrowserSession {
504
507
  private readonly maxOutstandingFrames: number;
505
508
 
506
509
  private browser?: plugins.puppeteer.Browser;
510
+ private video?: LiveBrowserVideo;
511
+ private frameCaptureEnabled: boolean;
507
512
  private browserContext?: plugins.puppeteer.BrowserContext;
508
513
  private browserLifetimeController?: AbortController;
509
514
  private browserDisconnectedListener?: () => void;
@@ -549,6 +554,14 @@ export class LiveBrowserSession {
549
554
  private lastError?: ILiveBrowserError;
550
555
 
551
556
  constructor(optionsArg: ILiveBrowserSessionOptions = {}) {
557
+ validateOptionalBoolean(optionsArg.screencast?.enabled, 'screencast.enabled');
558
+ this.frameCaptureEnabled = optionsArg.screencast?.enabled ?? true;
559
+ if (optionsArg.video) {
560
+ normalizeLiveVideoOptions(optionsArg.video);
561
+ if (!(optionsArg.usePipe ?? optionsArg.launchOptions?.pipe ?? true)) {
562
+ throw new Error('Browser video requires the private CDP pipe transport.');
563
+ }
564
+ }
552
565
  if (
553
566
  optionsArg.launchOptions?.protocol
554
567
  && optionsArg.launchOptions.protocol !== 'cdp'
@@ -655,6 +668,7 @@ export class LiveBrowserSession {
655
668
  viewport: { ...this.viewport },
656
669
  tabs: [...this.tabs.values()].map((tab) => this.createTabState(tab)),
657
670
  ...(this.lastError ? { lastError: { ...this.lastError } } : {}),
671
+ ...(this.video ? { videoAcceleration: this.video.getAcceleration() } : {}),
658
672
  };
659
673
  }
660
674
 
@@ -766,6 +780,10 @@ export class LiveBrowserSession {
766
780
  usePipe: this.options.usePipe,
767
781
  launchOptions: {
768
782
  ...this.options.launchOptions,
783
+ ...(this.options.video ? {
784
+ enableExtensions: true,
785
+ args: this.videoLaunchArguments(),
786
+ } : {}),
769
787
  protocol: 'cdp',
770
788
  signal: browserLifetimeController.signal,
771
789
  },
@@ -808,8 +826,14 @@ export class LiveBrowserSession {
808
826
  throw signal.reason;
809
827
  }
810
828
  this.browserContext = this.browser.defaultBrowserContext();
829
+ if (this.options.video) {
830
+ if (this.options.launchOptions?.args?.includes('--disable-extensions')) {
831
+ throw new Error('Browser video requires its private capture extension.');
832
+ }
833
+ this.video = new LiveBrowserVideo(this.browser, this.options.video);
834
+ }
811
835
  this.browserTargetCreatedListener = (target) => {
812
- if (target.type() !== 'page' || target.browserContext() !== this.browserContext) {
836
+ if (this.video?.ownsTarget(target) || target.type() !== 'page' || target.browserContext() !== this.browserContext) {
813
837
  return;
814
838
  }
815
839
  void target.page().then((page) => {
@@ -824,6 +848,7 @@ export class LiveBrowserSession {
824
848
  };
825
849
  this.browser.on('targetcreated', this.browserTargetCreatedListener);
826
850
  await this.configureBrowserSecurity(this.browser);
851
+ await this.video?.start();
827
852
  this.browserDisconnectedListener = () => {
828
853
  if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
829
854
  return;
@@ -844,7 +869,7 @@ export class LiveBrowserSession {
844
869
  };
845
870
  this.browser.on('disconnected', this.browserDisconnectedListener);
846
871
 
847
- const initialPages = await this.browserContext.pages();
872
+ const initialPages = (await this.browserContext.pages()).filter(page => !this.video?.ownsTarget(page.target()));
848
873
  if (initialPages.length === 0) {
849
874
  initialPages.push(await this.browserContext.newPage());
850
875
  }
@@ -969,6 +994,71 @@ export class LiveBrowserSession {
969
994
  );
970
995
  }
971
996
 
997
+ private videoLaunchArguments(): string[] {
998
+ const args = [...(this.options.launchOptions?.args ?? [])];
999
+ if (this.options.video?.gpu === 'disabled') return [...args, '--disable-gpu'];
1000
+ if (args.includes('--disable-gpu')) throw new Error('Use video.gpu="disabled" to disable video GPU acceleration.');
1001
+ if (!args.includes('--enable-gpu')) args.push('--enable-gpu');
1002
+ if (process.platform === 'linux') {
1003
+ if (!args.some(arg => arg.startsWith('--use-angle='))) args.push('--use-angle=vulkan');
1004
+ if (args.includes('--use-angle=vulkan')) {
1005
+ const features = new Set(args.filter(arg => arg.startsWith('--enable-features='))
1006
+ .flatMap(arg => arg.slice('--enable-features='.length).split(',')));
1007
+ features.add('Vulkan');
1008
+ for (let index = args.length - 1; index >= 0; index--) {
1009
+ if (args[index]!.startsWith('--enable-features=')) args.splice(index, 1);
1010
+ }
1011
+ args.push(`--enable-features=${[...features].join(',')}`);
1012
+ if (!args.includes('--disable-vulkan-surface')) args.push('--disable-vulkan-surface');
1013
+ }
1014
+ }
1015
+ return args;
1016
+ }
1017
+
1018
+ public setFrameCaptureEnabled(enabled: boolean, options: ILiveBrowserOperationOptions = {}): Promise<void> {
1019
+ if (typeof enabled !== 'boolean') throw new Error('Frame capture enabled must be a boolean.');
1020
+ return this.enqueuePublicOperation(async signal => {
1021
+ signal.throwIfAborted();
1022
+ if (enabled === this.frameCaptureEnabled) return;
1023
+ const tab = this.requireActiveTab();
1024
+ if (!tab.cdpSession || !tab.streaming) throw new Error('Browser input transport is unavailable.');
1025
+ if (enabled) {
1026
+ await tab.cdpSession.send('Page.startScreencast', this.screencastParameters());
1027
+ } else {
1028
+ await tab.cdpSession.send('Page.stopScreencast');
1029
+ await this.retireOutstandingFrames(frame => frame.tabId === tab.id);
1030
+ }
1031
+ this.frameCaptureEnabled = enabled;
1032
+ }, options);
1033
+ }
1034
+
1035
+ public openVideoPeer(peerId: string, options: ILiveBrowserOperationOptions = {}): Promise<ILiveVideoOffer> {
1036
+ return this.enqueuePublicOperation(async signal => {
1037
+ const tab = this.requireActiveTab();
1038
+ if (!this.video) throw new Error('Browser video is not enabled.');
1039
+ return this.video.open(peerId, tab.page, { tabId: tab.id, generation: tab.generation,
1040
+ viewportRevision: this.viewportRevision, viewport: { ...this.viewport } }, signal);
1041
+ }, options);
1042
+ }
1043
+
1044
+ public answerVideoPeer(peerId: string, negotiationId: string, description: ILiveVideoDescription,
1045
+ options: ILiveBrowserOperationOptions = {}): Promise<void> {
1046
+ return this.enqueuePublicOperation(async signal => {
1047
+ signal.throwIfAborted();
1048
+ if (!this.video) throw new Error('Browser video is not enabled.');
1049
+ await this.video.answer(peerId, negotiationId, description);
1050
+ }, options);
1051
+ }
1052
+
1053
+ public closeVideoPeer(peerId: string): Promise<void> {
1054
+ return this.video?.close(peerId) ?? Promise.resolve();
1055
+ }
1056
+
1057
+ public getVideoStatistics(peerId: string): Promise<ILiveVideoStatistics> {
1058
+ if (!this.video) return Promise.reject(new Error('Browser video is not enabled.'));
1059
+ return this.video.getStatistics(peerId);
1060
+ }
1061
+
972
1062
  public async updateScreencastOptions(
973
1063
  optionsArg: ILiveBrowserScreencastUpdateOptions,
974
1064
  operationOptions: ILiveBrowserOperationOptions = {},
@@ -985,7 +1075,7 @@ export class LiveBrowserSession {
985
1075
  const activeTab = this.status === 'running' && this.activeTabId
986
1076
  ? this.tabs.get(this.activeTabId)
987
1077
  : undefined;
988
- if (!activeTab?.streaming) {
1078
+ if (!activeTab?.streaming || !this.frameCaptureEnabled) {
989
1079
  return null;
990
1080
  }
991
1081
  return this.restartActiveScreencast(signal);
@@ -995,6 +1085,7 @@ export class LiveBrowserSession {
995
1085
  private async restartActiveScreencast(
996
1086
  signal: AbortSignal,
997
1087
  ): Promise<ILiveBrowserFrameIdentity> {
1088
+ if (!this.frameCaptureEnabled) throw new Error('Frame capture is not enabled.');
998
1089
  signal.throwIfAborted();
999
1090
  const tab = this.requireActiveTab();
1000
1091
  let firstFrame: ReturnType<LiveBrowserSession['waitForScreencastFrame']> | undefined;
@@ -2274,7 +2365,7 @@ export class LiveBrowserSession {
2274
2365
  status: tab.status,
2275
2366
  generation: tab.generation,
2276
2367
  appliedViewportRevision: tab.appliedViewportRevision,
2277
- streaming: tab.streaming,
2368
+ streaming: tab.streaming && !tab.streamInvalidated,
2278
2369
  };
2279
2370
  }
2280
2371
 
@@ -2540,6 +2631,10 @@ export class LiveBrowserSession {
2540
2631
  this.browserDisconnectedListener = undefined;
2541
2632
 
2542
2633
  const shutdownErrors: unknown[] = [];
2634
+ if (this.video) {
2635
+ await this.video.stop().catch(error => shutdownErrors.push(error));
2636
+ this.video = undefined;
2637
+ }
2543
2638
  const shutdownFrameDrainPromise = this.shutdownFrameDrainPromise;
2544
2639
  this.shutdownFrameDrainPromise = undefined;
2545
2640
  if (shutdownFrameDrainPromise) {
@@ -2971,7 +3066,9 @@ export class LiveBrowserSession {
2971
3066
  session.send('Fetch.continueWithAuth', {
2972
3067
  requestId: event.requestId,
2973
3068
  authChallengeResponse,
2974
- }, proxySecurityCommandOptions).then(() => undefined),
3069
+ }, proxySecurityCommandOptions).then(() => undefined).catch(error => (
3070
+ this.reconcileClosedProxyRequest(proxySecuritySession, error)
3071
+ )),
2975
3072
  generation,
2976
3073
  'proxy_security_protocol_failed',
2977
3074
  );
@@ -3008,7 +3105,9 @@ export class LiveBrowserSession {
3008
3105
  'Fetch.continueRequest',
3009
3106
  { requestId: event.requestId },
3010
3107
  proxySecurityCommandOptions,
3011
- ).then(() => undefined),
3108
+ ).then(() => undefined).catch(error => (
3109
+ this.reconcileClosedProxyRequest(proxySecuritySession, error)
3110
+ )),
3012
3111
  generation,
3013
3112
  'proxy_security_protocol_failed',
3014
3113
  );
@@ -3156,6 +3255,7 @@ export class LiveBrowserSession {
3156
3255
  private async waitForProxySecurityTargetCoverage(
3157
3256
  targetId: string,
3158
3257
  generation: number,
3258
+ excludedSession?: plugins.puppeteer.CDPSession,
3159
3259
  ): Promise<boolean> {
3160
3260
  for (let attempt = 0; attempt < 3; attempt += 1) {
3161
3261
  const generationSessions = [...this.proxySecuritySessions.values()].filter((candidate) => (
@@ -3165,6 +3265,7 @@ export class LiveBrowserSession {
3165
3265
  const hasReplacement = [...this.proxySecuritySessions.values()].some((candidate) => (
3166
3266
  candidate.generation === generation
3167
3267
  && candidate.targetId === targetId
3268
+ && candidate.session !== excludedSession
3168
3269
  && candidate.fetchEnabled
3169
3270
  && !candidate.session.detached
3170
3271
  ));
@@ -3187,6 +3288,22 @@ export class LiveBrowserSession {
3187
3288
  return !targetInfos.some((targetInfo) => targetInfo.targetId === targetId);
3188
3289
  }
3189
3290
 
3291
+ private async reconcileClosedProxyRequest(
3292
+ record: IProxySecuritySession,
3293
+ error: unknown,
3294
+ ): Promise<void> {
3295
+ if (this.proxySecurityStopping || this.normalStopRequested
3296
+ || record.generation !== this.proxySecurityGeneration) return;
3297
+ // An in-flight Fetch command can reject after its worker/session has retired.
3298
+ // Retain the original record: detach handling may already have removed it.
3299
+ await record.setupPromise;
3300
+ if (!record.session.detached || !record.targetId) throw error;
3301
+ if (await this.waitForProxySecurityTargetCoverage(
3302
+ record.targetId, record.generation, record.session,
3303
+ )) return;
3304
+ throw error;
3305
+ }
3306
+
3190
3307
  private removeProxySecuritySession(proxySecuritySession: IProxySecuritySession): void {
3191
3308
  if (this.proxySecuritySessions.get(proxySecuritySession.session.id()) !== proxySecuritySession) {
3192
3309
  return;
@@ -4195,17 +4312,11 @@ export class LiveBrowserSession {
4195
4312
  cdpSessionDetachedListener,
4196
4313
  );
4197
4314
 
4198
- const format = this.options.screencast?.format ?? 'jpeg';
4199
- await this.waitForScreencastAuthority(
4200
- cdpSession.send('Page.startScreencast', {
4201
- format,
4202
- quality: this.options.screencast?.quality ?? 80,
4203
- maxWidth: this.options.screencast?.maxWidth,
4204
- maxHeight: this.options.screencast?.maxHeight,
4205
- everyNthFrame: this.options.screencast?.everyNthFrame ?? 1,
4206
- }),
4207
- authority,
4208
- );
4315
+ if (this.frameCaptureEnabled) {
4316
+ await this.waitForScreencastAuthority(
4317
+ cdpSession.send('Page.startScreencast', this.screencastParameters()), authority,
4318
+ );
4319
+ }
4209
4320
  this.assertScreencastAuthority(tab, authority);
4210
4321
  this.emitState();
4211
4322
  } catch (error) {
@@ -4318,6 +4429,7 @@ export class LiveBrowserSession {
4318
4429
  }
4319
4430
 
4320
4431
  private invalidateScreencast(tab: IPrivateLiveBrowserTab, reason: unknown): void {
4432
+ const wasStreaming = tab.streaming && !tab.streamInvalidated;
4321
4433
  tab.streamInvalidated = true;
4322
4434
  tab.streamLifecycleRevision += 1;
4323
4435
  const authority = tab.screencastAuthority;
@@ -4325,6 +4437,9 @@ export class LiveBrowserSession {
4325
4437
  if (authority && !authority.controller.signal.aborted) {
4326
4438
  authority.controller.abort(reason);
4327
4439
  }
4440
+ // Input authority is revoked synchronously on renderer navigation. Publish
4441
+ // that same boundary before the queued page/title refresh can yield.
4442
+ if (wasStreaming) this.emitState();
4328
4443
  }
4329
4444
 
4330
4445
  private waitForScreencastFrame(
@@ -4377,7 +4492,15 @@ export class LiveBrowserSession {
4377
4492
  };
4378
4493
  }
4379
4494
 
4495
+ private screencastParameters(): plugins.puppeteer.Protocol.Page.StartScreencastRequest {
4496
+ return { format: this.options.screencast?.format ?? 'jpeg',
4497
+ quality: this.options.screencast?.quality ?? 80,
4498
+ maxWidth: this.options.screencast?.maxWidth, maxHeight: this.options.screencast?.maxHeight,
4499
+ everyNthFrame: this.options.screencast?.everyNthFrame ?? 1 };
4500
+ }
4501
+
4380
4502
  private async stopScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
4503
+ if (this.video && this.activeTabId === tab.id) await this.video.invalidate();
4381
4504
  const cdpSession = tab.cdpSession;
4382
4505
  const cdpConnection = tab.cdpConnection;
4383
4506
  const frameListener = tab.screencastFrameListener;
@@ -1,4 +1,5 @@
1
1
  import type { IEnvAwareOptions } from './smartpuppeteer.classes.smartpuppeteer.js';
2
+ import type { ILiveVideoOptions, ILiveVideoAcceleration } from './interfaces.livevideo.js';
2
3
 
3
4
  export type TLiveBrowserStatus = 'stopped' | 'starting' | 'running' | 'stopping';
4
5
  export type TLiveBrowserImageFormat = 'jpeg' | 'png';
@@ -19,6 +20,8 @@ export interface ILiveBrowserViewport {
19
20
  }
20
21
 
21
22
  export interface ILiveBrowserScreencastOptions {
23
+ /** JPEG/PNG production can be enabled on demand independently of input. */
24
+ enabled?: boolean;
22
25
  format?: TLiveBrowserImageFormat;
23
26
  quality?: number;
24
27
  maxWidth?: number;
@@ -79,6 +82,7 @@ export interface ILiveBrowserSessionOptions extends Omit<IEnvAwareOptions, 'laun
79
82
  launchOptions?: TLiveBrowserLaunchOptions;
80
83
  viewport?: ILiveBrowserViewport;
81
84
  screencast?: ILiveBrowserScreencastOptions;
85
+ video?: ILiveVideoOptions;
82
86
  security?: ILiveBrowserSecurityOptions;
83
87
  allowEvaluation?: boolean;
84
88
  }
@@ -129,6 +133,7 @@ export interface ILiveBrowserState {
129
133
  viewport: ILiveBrowserViewport;
130
134
  tabs: ILiveBrowserTabState[];
131
135
  lastError?: ILiveBrowserError;
136
+ videoAcceleration?: ILiveVideoAcceleration;
132
137
  }
133
138
 
134
139
  export interface ILiveBrowserScreencastMetadata {
@@ -3,8 +3,10 @@ import { Buffer } from 'node:buffer';
3
3
  import * as fs from 'node:fs';
4
4
  import * as http from 'node:http';
5
5
  import * as os from 'node:os';
6
+ import * as crypto from 'node:crypto';
7
+ import * as url from 'node:url';
6
8
 
7
- export { Buffer, fs, http, os };
9
+ export { Buffer, fs, http, os, crypto, url };
8
10
 
9
11
  // @pushrocks scope
10
12
  import * as smartdelay from '@push.rocks/smartdelay';
package/readme.hints.md DELETED
@@ -1,20 +0,0 @@
1
- # Implementation Hints
2
-
3
- - `getEnvAwareBrowserInstance()` is the only Chromium launch path. Merge caller arguments before adding environment-required sandbox arguments, retain the pipe default, and do not run executable discovery when the caller selected a browser, channel, or executable.
4
- - `LiveBrowserSession` uses the browser's default context so all tabs and popups share one profile. Omitting both `launchOptions.userDataDir` and a `--user-data-dir` argument intentionally relies on Puppeteer's ephemeral profile lifecycle.
5
- - CDP is private to the live runtime. Public contracts contain transport-neutral values and `Uint8Array` image data, never `CDPSession`, raw CDP frame IDs, or base64 image strings.
6
- - Every published screencast frame has one private sequence-to-CDP acknowledgement entry. Public acknowledgement requires matching tab ID, sequence, generation, and viewport revision. `screencast.maxOutstandingFrames` bounds those entries independently of any application transport window; overflow retires oldest-first. Drops and all stream invalidation paths must issue each CDP acknowledgement at most once and await in-flight acknowledgements before detaching the CDP session.
7
- - `refreshScreencast()` installs its exact-generation waiter before starting CDP, retires the previous stream, and resolves only after the first validated frame is published. Pre-aborted and queued calls reject without changing the stream. Active caller cancellation settles only after restorative restart; shutdown, tab replacement, crash, or closure revokes restart authority and must never resurrect the stream.
8
- - A refresh timeout or restart failure on a tab that is still restorable is not runtime-fatal. Revoke the restart authority, wait for the in-flight `startScreencast()` to unwind, stop whatever it established, and emit non-fatal `screencast_refresh_failed` with the tab left open, non-streaming, and invalidated. `refreshScreencast()` therefore also accepts a non-streaming active tab; `updateScreencastOptions()` shares that restart core after storing validated options. Browser loss stays fatal and is normally reported by the disconnect listener first.
9
- - Raw input is dispatched through a per-tab queue. Chromium does not keep a pipelined mouse or key command behind an earlier `mouseWheel` command, so anything queued behind wheel input waits until that wheel CDP call settles. Wheel input arriving while a wheel dispatch is in flight merges into the trailing wheel entry for the same CDP session, generation, and viewport revision; a non-wheel entry ends that batch.
10
- - Activation, viewport changes, navigation, tab closure, snapshots, observations, semantic actions, and shutdown share one bounded operation scheduler. Raw input and frame acknowledgement remain direct, but must validate active tab, generation, and viewport revision. Repeated internal navigation/load state updates are coalesced per tab, and shutdown cancels queued work.
11
- - Retain the scheduler-owned launch `AbortController` for the full browser lifetime. Shutdown aborts both the active operation and Chromium itself so a non-signal-aware Puppeteer command or disabled protocol timeout cannot retain the browser ahead of queued cleanup.
12
- - Viewport revision starts at 1 and advances only after `Page.setViewport()` succeeds. Stop and flush the active screencast before applying a viewport or navigation mutation, then restart it with a new generation.
13
- - Track the applied viewport revision on every tab. Before an inactive tab is captured, observed, activated, or acted upon, apply the current global viewport and report metadata from that exact viewport. Full-page capture is outside the initial live-runtime scope.
14
- - Canonicalize the configured viewport once. `viewport` wins over `launchOptions.defaultViewport`, `null` uses 800x600, and every Puppeteer viewport explicitly disables mobile, landscape, and touch emulation. The operation scheduler owns `LaunchOptions.signal`; never accept a caller signal for `LiveBrowserSession`.
15
- - New-page registration and navigation are transactional: validate before creating a page where possible and restore page, listener, tab-map, active-tab, viewport, and screencast ownership on failure. Keep listeners attached during `page.close()` so a failed close remains observed and recoverable.
16
- - Browser disconnect is runtime-fatal. Page crash or current CDP-session loss is tab-scoped: activate another usable tab, trying all candidates, or stop cleanly when none remains.
17
- - Every popup is either registered, closed, or escalated to browser-wide shutdown. Queue saturation and startup-time popup events must never leave an untracked live page.
18
- - Authenticated proxy coverage owns a filtered browser-target auto-attach scope for service workers. Configure Fetch and Network before resuming each owned worker, use the hidden page-target lifecycle observer to release stopped or request-drained redundant workers, bound every security command and detach wait, and keep Puppeteer's target sessions independent.
19
- - A navigation metadata refresh failure must not consume screencast restoration ownership. Restore the active stream first, preserve newer navigation revisions, and report the metadata failure separately.
20
- - Verify Puppeteer behavior against the installed Puppeteer 25 declarations and implementation. Do not add a direct `devtools-protocol` dependency; Puppeteer's public protocol typing is sufficient for private CDP calls.