scrapely 1.2.26 → 1.2.27

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,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PUPPETEER_NODE_ESM_SUFFIX = exports.PUPPETEER_NODE_ESM_URL_RE = exports.PUPPETEER_NODE_PATCH_GLOBAL = void 0;
3
4
  exports.setCdpUrl = setCdpUrl;
4
5
  exports.getCdpUrlForTests = getCdpUrlForTests;
5
6
  exports.resetBrowserProxyWarningsForTests = resetBrowserProxyWarningsForTests;
@@ -7,7 +8,11 @@ exports.applyProxyInfoToCdpUrl = applyProxyInfoToCdpUrl;
7
8
  exports.patchBrowserPoolModule = patchBrowserPoolModule;
8
9
  exports.patchCrawleeCrawlerModule = patchCrawleeCrawlerModule;
9
10
  exports.patchLoadedCrawleeCopies = patchLoadedCrawleeCopies;
11
+ exports.patchBrowserLauncherModule = patchBrowserLauncherModule;
10
12
  exports.patchRawBrowserType = patchRawBrowserType;
13
+ exports.patchPuppeteerNodeClass = patchPuppeteerNodeClass;
14
+ exports.patchLoadedModules = patchLoadedModules;
15
+ exports.esmPuppeteerInstalled = esmPuppeteerInstalled;
11
16
  /**
12
17
  * Redirects every browser launch (Crawlee browser pool, raw playwright /
13
18
  * puppeteer) to the Scrapely remote browser over CDP. The remote browser
@@ -19,6 +24,7 @@ exports.patchRawBrowserType = patchRawBrowserType;
19
24
  const browser_configuration_js_1 = require("./browser-configuration.js");
20
25
  const browser_locations_js_1 = require("./browser-locations.js");
21
26
  const cdp_retry_js_1 = require("./cdp-retry.js");
27
+ const node_wrapper_js_1 = require("./node-wrapper.js");
22
28
  let _cdpUrl;
23
29
  /**
24
30
  * Lazily get CDP URL from environment or cached value.
@@ -485,52 +491,79 @@ function patchLoadedCrawleeCopies() {
485
491
  }
486
492
  }
487
493
  }
488
- try {
489
- // Use Crawlee's ow (0.28.x CJS). Do not import top-level ow@1.x (ESM-only).
490
- const owModule = require(require.resolve('ow', { paths: [require.resolve('@crawlee/browser')] }));
491
- const ow = owModule.default ?? owModule;
492
- // Playwright + Puppeteer plugins of the SDK's own @crawlee/browser-pool copy.
493
- // Actor-local copies are patched by the Module._load hook further down.
494
- try {
495
- patchBrowserPoolModule(require('@crawlee/browser-pool'));
496
- }
497
- catch {
498
- // @crawlee/browser-pool not installed — skip plugin patches
499
- }
500
- try {
501
- const launcherPath = require.resolve('@crawlee/playwright').replace(/[/\\][^/\\]+$/, '') +
502
- '/internals/playwright-launcher.js';
503
- const { PlaywrightLauncher } = require(launcherPath);
504
- PlaywrightLauncher.optionsShape.cdpUrl = ow.optional.string;
505
- }
506
- catch {
507
- // @crawlee/playwright not installed — skip Playwright patches
508
- }
509
- try {
510
- const puppeteerLauncherPath = require.resolve('@crawlee/puppeteer').replace(/[/\\][^/\\]+$/, '') +
511
- '/internals/puppeteer-launcher.js';
512
- const { PuppeteerLauncher } = require(puppeteerLauncherPath);
513
- PuppeteerLauncher.optionsShape.cdpUrl = ow.optional.string;
494
+ // ─── Crawlee launchers: `cdpUrl` option + gVisor-safe local launch args ─────
495
+ //
496
+ // Applied when a launcher module loads (Module._load hook below), never by
497
+ // importing Crawlee up front: loading @crawlee/browser-pool and playwright
498
+ // eagerly cost ~2.4 s per Node process under gVisor.
499
+ /** Requests that load a Crawlee launcher file (`require('./internals/browser-launcher')`). */
500
+ const LAUNCHER_REQUEST_RE = /(^|[/\\])internals[/\\](browser|playwright|puppeteer)-launcher(\.js)?$/;
501
+ /** Cached module filenames of Crawlee launcher files (any copy). */
502
+ const LAUNCHER_FILENAME_RE = /[/\\]@crawlee[/\\](browser|playwright|puppeteer)[/\\]internals[/\\](browser|playwright|puppeteer)-launcher\.js$/;
503
+ /**
504
+ * An `ow.optional.string` predicate from the same ow copy as the launcher's
505
+ * own shape (a predicate from another ow copy is not recognized by
506
+ * exactShape). Reuses an existing optional-string entry, else loads ow next
507
+ * to the module that required the launcher.
508
+ */
509
+ function optionalStringPredicate(shape, parentFilename) {
510
+ if (shape.userAgent)
511
+ return shape.userAgent;
512
+ if (shape.userDataDir)
513
+ return shape.userDataDir;
514
+ if (!parentFilename)
515
+ return undefined;
516
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
517
+ const { createRequire } = require('module');
518
+ const owModule = createRequire(parentFilename)('ow');
519
+ return (owModule.default ?? owModule).optional.string;
520
+ }
521
+ function addCdpUrlOption(Launcher, parentFilename) {
522
+ const shape = Launcher?.optionsShape;
523
+ if (!shape || typeof shape !== 'object' || 'cdpUrl' in shape)
524
+ return;
525
+ const predicate = optionalStringPredicate(shape, parentFilename);
526
+ if (predicate)
527
+ shape.cdpUrl = predicate;
528
+ }
529
+ /**
530
+ * Patch the launcher classes exported by one loaded Crawlee launcher file (or
531
+ * package index). Idempotent.
532
+ *
533
+ * - `cdpUrl` becomes a valid launcher option (PlaywrightLauncher and
534
+ * PuppeteerLauncher copy BrowserLauncher's shape when their file loads, so
535
+ * each class gets it; browser-launcher.js loads first, so the copies inherit it).
536
+ * - Local launches get gVisor-safe Chrome args under runsc. Playwright/Puppeteer
537
+ * pool launches use CDP and skip this.
538
+ *
539
+ * @internal Exported for tests.
540
+ */
541
+ function patchBrowserLauncherModule(mod, parentFilename) {
542
+ if (!mod || (typeof mod !== 'object' && typeof mod !== 'function'))
543
+ return;
544
+ for (const key of ['BrowserLauncher', 'PlaywrightLauncher', 'PuppeteerLauncher']) {
545
+ let Launcher;
546
+ try {
547
+ Launcher = mod[key];
548
+ }
549
+ catch {
550
+ continue;
551
+ }
552
+ if (typeof Launcher !== 'function')
553
+ continue;
554
+ addCdpUrlOption(Launcher, parentFilename);
514
555
  }
515
- catch {
516
- // @crawlee/puppeteer not installed — plugin patch above is enough
517
- }
518
- // Patch @crawlee/browser BrowserLauncher when available
519
- const browserPkg = require.resolve('@crawlee/browser');
520
- const browserLauncherPath = browserPkg.replace(/[/\\][^/\\]+$/, '') + '/internals/browser-launcher.js';
521
- const { BrowserLauncher } = require(browserLauncherPath);
522
- BrowserLauncher.optionsShape.cdpUrl = ow.optional.string;
523
- // Patch createLaunchOptions to inject gVisor-safe Chrome args for any residual
524
- // local launch path. Playwright/Puppeteer pool launches use CDP and skip this.
525
- // Only applies when SCRAPELY_CONTAINER_RUNTIME=runsc (gVisor sandbox).
526
- const origCreateLaunchOptions = BrowserLauncher.prototype.createLaunchOptions;
527
- BrowserLauncher.prototype.createLaunchOptions = function () {
528
- const launchOptions = origCreateLaunchOptions.call(this);
529
- // Inject crashpad-disabling args when running under gVisor
530
- // gVisor doesn't support Chrome's crashpad_handler process
531
- // Also needed with ReadonlyRootfs + tmpfs /tmp where /tmp/.X11-unix
532
- // and crashpad database dir don't persist from the base image.
533
- if (process.env.SCRAPELY_CONTAINER_RUNTIME === 'runsc') {
556
+ const BrowserLauncher = mod.BrowserLauncher;
557
+ const proto = BrowserLauncher?.prototype;
558
+ if (!proto || typeof proto.createLaunchOptions !== 'function' || proto._scrapelyGVisorArgsPatched)
559
+ return;
560
+ const origCreateLaunchOptions = proto.createLaunchOptions;
561
+ proto.createLaunchOptions = function (...args) {
562
+ const launchOptions = origCreateLaunchOptions.apply(this, args);
563
+ // gVisor doesn't support Chrome's crashpad_handler process. Also needed
564
+ // with ReadonlyRootfs + tmpfs /tmp where /tmp/.X11-unix and the crashpad
565
+ // database dir don't persist from the base image.
566
+ if (process.env.SCRAPELY_CONTAINER_RUNTIME === 'runsc' && launchOptions) {
534
567
  launchOptions.args = launchOptions.args || [];
535
568
  for (const arg of GVISOR_CHROME_ARGS) {
536
569
  if (!launchOptions.args.includes(arg)) {
@@ -540,9 +573,7 @@ try {
540
573
  }
541
574
  return launchOptions;
542
575
  };
543
- }
544
- catch {
545
- // @crawlee/browser not installed — skip browser pool patches (e.g. cheerio-only actors)
576
+ proto._scrapelyGVisorArgsPatched = true;
546
577
  }
547
578
  /**
548
579
  * Split our `browserConfiguration` option off raw launch options. The options
@@ -681,72 +712,87 @@ function patchRawPlaywrightModule(pw) {
681
712
  }
682
713
  }
683
714
  /**
684
- * Patch a raw puppeteer launcher: the default export (also exposed as module
685
- * props). Both the full puppeteer package and puppeteer-core ship the same
686
- * launcher shape (launch/connect) — patch whichever was loaded.
715
+ * The patched puppeteer `launch`: remote CDP connect on the platform, the
716
+ * original local launch otherwise. `connect` is the launcher's (retry-wrapped)
717
+ * connect; `origLaunch` the unpatched launch, both called with `self`.
718
+ */
719
+ async function puppeteerLaunchPatched(self, origLaunch, moduleName, launchOptions = {}) {
720
+ const { options, browserConfiguration } = takeBrowserConfiguration(launchOptions);
721
+ if (browserPatchDisabled())
722
+ return origLaunch.call(self, withGVisorArgs(options));
723
+ const cdpUrl = rawLaunchCdpUrl(options, browserConfiguration);
724
+ if (!cdpUrl) {
725
+ if (!isAtHome())
726
+ return origLaunch.call(self, options);
727
+ throw new Error('cdpUrl is required. Local browser launch is disabled — all runs must use the remote browser.');
728
+ }
729
+ const dropped = Object.keys(options).filter((k) => k !== 'timeout' && k !== 'slowMo' && k !== 'protocol');
730
+ if (dropped.length > 0) {
731
+ console.warn(`[scrapely] ${moduleName}.launch redirected to the remote CDP browser; ` +
732
+ `local launch options ignored: ${dropped.join(', ')}.`);
733
+ }
734
+ return self.connect({ browserWSEndpoint: cdpUrl, ...rawConnectOptions(options) });
735
+ }
736
+ /**
737
+ * Patch puppeteer's PuppeteerNode class (puppeteer-core `node/PuppeteerNode.js`,
738
+ * shared by puppeteer and puppeteer-core, CJS and ESM builds). The package
739
+ * entry creates its default instance right after this class loads, and the
740
+ * constructor binds `launch`/`connect` from the prototype, so patching the
741
+ * prototype first also covers the named exports (`import { launch } from
742
+ * 'puppeteer'`), which are those bound methods. Idempotent per class.
743
+ *
744
+ * @internal Exported for tests.
745
+ */
746
+ function patchPuppeteerNodeClass(PuppeteerNode) {
747
+ const proto = PuppeteerNode?.prototype;
748
+ if (!proto || typeof proto.launch !== 'function')
749
+ return;
750
+ if (Object.prototype.hasOwnProperty.call(proto, '_scrapelyRawCdpPatched'))
751
+ return;
752
+ (0, cdp_retry_js_1.wrapConnectWithRetry)(proto, 'connect', getCdpUrl);
753
+ const origLaunch = proto.launch;
754
+ proto.launch = function rawPuppeteerLaunchPatched(launchOptions) {
755
+ const moduleName = this?._isPuppeteerCore === false ? 'puppeteer' : 'puppeteer-core';
756
+ return puppeteerLaunchPatched(this, origLaunch, moduleName, launchOptions);
757
+ };
758
+ proto._scrapelyRawCdpPatched = true;
759
+ }
760
+ /**
761
+ * Patch a raw puppeteer launcher instance: the default export (also exposed as
762
+ * module props). Covers puppeteer copies whose instance already exists (loaded
763
+ * before this module) or whose class could not be patched first. Skipped when
764
+ * the instance's class is already patched.
687
765
  */
688
766
  function patchRawPuppeteerModule(mod, moduleName) {
689
767
  const launcher = mod?.default ?? mod;
690
768
  if (typeof launcher?.launch !== 'function' || launcher._scrapelyRawCdpPatched) {
691
769
  return;
692
770
  }
771
+ const origBoundLaunch = launcher.launch;
772
+ const origBoundConnect = launcher.connect;
693
773
  (0, cdp_retry_js_1.wrapConnectWithRetry)(launcher, 'connect', getCdpUrl);
694
774
  const origLaunch = launcher.launch.bind(launcher);
695
775
  launcher.launch = async function rawPuppeteerLaunchPatched(launchOptions = {}) {
696
- const { options, browserConfiguration } = takeBrowserConfiguration(launchOptions);
697
- if (browserPatchDisabled())
698
- return origLaunch(withGVisorArgs(options));
699
- const cdpUrl = rawLaunchCdpUrl(options, browserConfiguration);
700
- if (!cdpUrl) {
701
- if (!isAtHome())
702
- return origLaunch(options);
703
- throw new Error('cdpUrl is required. Local browser launch is disabled — all runs must use the remote browser.');
704
- }
705
- const dropped = Object.keys(options).filter((k) => k !== 'timeout' && k !== 'slowMo' && k !== 'protocol');
706
- if (dropped.length > 0) {
707
- console.warn(`[scrapely] ${moduleName}.launch redirected to the remote CDP browser; ` +
708
- `local launch options ignored: ${dropped.join(', ')}.`);
709
- }
710
- return launcher.connect({ browserWSEndpoint: cdpUrl, ...rawConnectOptions(options) });
776
+ return puppeteerLaunchPatched(launcher, origLaunch, moduleName, launchOptions);
711
777
  };
712
778
  launcher._scrapelyRawCdpPatched = true;
713
- }
714
- /**
715
- * Eager pass: patch whatever playwright/puppeteer is resolvable from this
716
- * module's own dependency tree right now (the SDK tree under
717
- * /opt/scrapely/node_modules in the base image). Actor-local installs are
718
- * covered by the Module._load hook below — a bare require() from this file
719
- * can never resolve them.
720
- */
721
- function patchSdkTreeRawModules() {
722
- for (const pwModuleName of ['playwright', 'playwright-core']) {
723
- try {
724
- // eslint-disable-next-line @typescript-eslint/no-var-requires
725
- patchRawPlaywrightModule(require(pwModuleName));
726
- }
727
- catch {
728
- // not installed in the SDK tree — actor-local copies still get the hook
729
- }
730
- }
731
- // Raw puppeteer API: default export launcher (also exposed as module props).
732
- // Both the full puppeteer package and puppeteer-core ship the same launcher
733
- // shape (launch/connect) — patch whichever is installed.
734
- for (const puppeteerModuleName of ['puppeteer', 'puppeteer-core']) {
735
- try {
736
- // eslint-disable-next-line @typescript-eslint/no-var-requires
737
- patchRawPuppeteerModule(require(puppeteerModuleName), puppeteerModuleName);
738
- }
739
- catch {
740
- // not installed in the SDK tree — actor-local copies still get the hook
779
+ // Named exports (`exports.launch = puppeteer.launch`) hold the original
780
+ // bound methods; point them at the patched ones.
781
+ if (mod && mod !== launcher) {
782
+ for (const [key, original] of [
783
+ ['launch', origBoundLaunch],
784
+ ['connect', origBoundConnect],
785
+ ]) {
786
+ try {
787
+ if (mod[key] === original && mod[key] !== launcher[key])
788
+ mod[key] = launcher[key];
789
+ }
790
+ catch {
791
+ // read-only export binding — the default export is still patched
792
+ }
741
793
  }
742
794
  }
743
795
  }
744
- /**
745
- * Apply the raw patches to a module coming back from the hooked loader.
746
- * `request` is the specifier as written by the caller, `loaded` is whatever
747
- * instance it actually resolved to, and `loadRoot` resolves a package root
748
- * through the original loader (no hook recursion) for subpath requests.
749
- */
750
796
  /**
751
797
  * Requests that load (a part of) some copy of @crawlee/browser-pool:
752
798
  * - the bare package name (require from @crawlee/playwright etc.),
@@ -754,7 +800,22 @@ function patchSdkTreeRawModules() {
754
800
  * - the relative plugin files the package's own index.js requires.
755
801
  */
756
802
  const BROWSER_POOL_REQUEST_RE = /^@crawlee\/browser-pool$|[/\\]@crawlee[/\\]browser-pool[/\\]index\.js$|(^|[/\\])(playwright|puppeteer)-plugin(\.js)?$/;
757
- function patchLoadedRawModule(request, loaded, loadRoot) {
803
+ /** Cached module filenames of @crawlee/browser-pool (index or plugin files, any copy). */
804
+ const BROWSER_POOL_FILENAME_RE = /[/\\]@crawlee[/\\]browser-pool[/\\](index|playwright[/\\]playwright-plugin|puppeteer[/\\]puppeteer-plugin)\.js$/;
805
+ /** playwright / playwright-core package entry, by filename (ESM imports and require.cache). */
806
+ const PLAYWRIGHT_ENTRY_FILENAME_RE = /[/\\]node_modules[/\\](playwright|playwright-core)[/\\]index\.js$/;
807
+ /** puppeteer / puppeteer-core CJS package entry (default instance), by filename. */
808
+ const PUPPETEER_ENTRY_FILENAME_RE = /[/\\]node_modules[/\\](puppeteer|puppeteer-core)[/\\]lib[/\\]cjs[/\\]puppeteer[/\\](puppeteer|puppeteer-core)\.js$/;
809
+ /** puppeteer-core's PuppeteerNode class file (CJS), as requested by node/node.js or cached. */
810
+ const PUPPETEER_NODE_REQUEST_RE = /(^|[/\\])PuppeteerNode(\.js)?$/;
811
+ /**
812
+ * Apply the patches to a module coming back from the hooked loader.
813
+ * `request` is the specifier as written by the caller (an absolute filename
814
+ * when ESM imports a CJS package), `loaded` is whatever instance it actually
815
+ * resolved to, and `loadRoot` resolves a package root through the original
816
+ * loader (no hook recursion) for subpath requests.
817
+ */
818
+ function patchLoadedRawModule(request, loaded, loadRoot, parentFilename) {
758
819
  if (typeof request !== 'string')
759
820
  return;
760
821
  // Crawlee browser pool — the actor's own copy (e.g. /usr/src/app/node_modules)
@@ -763,18 +824,31 @@ function patchLoadedRawModule(request, loaded, loadRoot) {
763
824
  patchBrowserPoolModule(loaded);
764
825
  return;
765
826
  }
827
+ if (LAUNCHER_REQUEST_RE.test(request)) {
828
+ patchBrowserLauncherModule(loaded, parentFilename);
829
+ return;
830
+ }
766
831
  if (CRAWLER_REQUEST_RE.test(request)) {
767
832
  patchCrawleeCrawlerModule(loaded);
768
833
  return;
769
834
  }
770
- if (request === 'playwright' || request === 'playwright-core') {
835
+ if (request === 'playwright' || request === 'playwright-core' || PLAYWRIGHT_ENTRY_FILENAME_RE.test(request)) {
771
836
  patchRawPlaywrightModule(loaded);
772
837
  return;
773
838
  }
839
+ if (PUPPETEER_NODE_REQUEST_RE.test(request)) {
840
+ patchPuppeteerNodeClass(loaded?.PuppeteerNode);
841
+ return;
842
+ }
774
843
  if (request === 'puppeteer' || request === 'puppeteer-core') {
775
844
  patchRawPuppeteerModule(loaded, request);
776
845
  return;
777
846
  }
847
+ const puppeteerEntry = PUPPETEER_ENTRY_FILENAME_RE.exec(request);
848
+ if (puppeteerEntry) {
849
+ patchRawPuppeteerModule(loaded, puppeteerEntry[1]);
850
+ return;
851
+ }
778
852
  // BrowserType subpath loads, e.g. require('playwright/chromium') — the
779
853
  // export is the BrowserType itself, not the package root. Resolve the root
780
854
  // for its chromium (the connectOverCDP anchor), patch the root, then the
@@ -796,15 +870,48 @@ function patchLoadedRawModule(request, loaded, loadRoot) {
796
870
  }
797
871
  }
798
872
  /**
799
- * Hook the CommonJS loader so the raw patches reach actor-local installs.
873
+ * `require('puppeteer')` of an ESM-only puppeteer (v25+) on Node without
874
+ * module.registerHooks (Node 20, 22.0-22.14): require(esm) bypasses both
875
+ * Module._load for its inner modules and the off-thread ESM hook, and the
876
+ * returned namespace is frozen, so the named `launch` export would keep the
877
+ * original. Load and patch the PuppeteerNode class first (same ESM module
878
+ * instance the entry imports), so the entry's default instance binds the
879
+ * patched methods. Older CJS builds are covered by the regular hook anyway.
880
+ */
881
+ const puppeteerEntriesPreloaded = new Set();
882
+ function preloadPuppeteerNodeClass(request, parent, origLoad) {
883
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
884
+ const Module = require('module');
885
+ if (typeof Module.registerHooks === 'function')
886
+ return;
887
+ const parentDir = typeof parent?.path === 'string' ? parent.path : '';
888
+ const key = `${parentDir}\0${request}`;
889
+ if (puppeteerEntriesPreloaded.has(key))
890
+ return;
891
+ puppeteerEntriesPreloaded.add(key);
892
+ try {
893
+ // puppeteer's own puppeteer-core (may be nested under puppeteer).
894
+ const entry = Module._resolveFilename(request, parent, false);
895
+ const coreRequest = 'puppeteer-core/internal/node/PuppeteerNode.js';
896
+ const corePath = Module.createRequire(entry).resolve(coreRequest);
897
+ if (!/[/\\]lib[/\\]puppeteer[/\\]node[/\\]PuppeteerNode\.js$/.test(corePath))
898
+ return;
899
+ patchPuppeteerNodeClass(origLoad(corePath, parent, false)?.PuppeteerNode);
900
+ }
901
+ catch {
902
+ // not installed / different layout: the regular patches still apply
903
+ }
904
+ }
905
+ /**
906
+ * Hook the CommonJS loader so the patches reach every copy of the browser
907
+ * libraries and Crawlee, whenever they load.
800
908
  *
801
- * The eager pass above can only see this module's own dependency tree; an
802
- * actor bundling playwright/puppeteer in its workdir resolves a different,
803
- * unpatched instance. Module._load is the chokepoint every require() passes
804
- * through AFTER resolution — including CJS packages imported from ESM, which
805
- * Node funnels through the CJS loader — so patching here covers whichever
806
- * copy (SDK tree or actor workdir) the request actually resolved to. Chained
807
- * onto the current _load (composes with tsx's hook in dev) and idempotent.
909
+ * Module._load is the chokepoint every require() passes through AFTER
910
+ * resolution — including CJS packages imported from ESM, which Node funnels
911
+ * through the CJS loader with the resolved filename — so patching here covers
912
+ * whichever copy (SDK tree or actor workdir) the request actually resolved to.
913
+ * Chained onto the current _load (composes with tsx's hook in dev) and
914
+ * idempotent.
808
915
  */
809
916
  function installRawModuleLoadHook() {
810
917
  // eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -813,9 +920,12 @@ function installRawModuleLoadHook() {
813
920
  if (typeof origLoad !== 'function' || origLoad._scrapelyRawCdpHook)
814
921
  return;
815
922
  const hookedLoad = function (request, parent, isMain) {
923
+ if (request === 'puppeteer' || request === 'puppeteer-core') {
924
+ preloadPuppeteerNodeClass(request, parent, origLoad);
925
+ }
816
926
  const loaded = origLoad.apply(this, [request, parent, isMain]);
817
927
  try {
818
- patchLoadedRawModule(request, loaded, (name) => origLoad(name, parent, isMain));
928
+ patchLoadedRawModule(request, loaded, (name) => origLoad(name, parent, isMain), typeof parent?.filename === 'string' ? parent.filename : undefined);
819
929
  }
820
930
  catch (err) {
821
931
  // Never break the actor's module loading — warn and hand back the
@@ -827,9 +937,134 @@ function installRawModuleLoadHook() {
827
937
  hookedLoad._scrapelyRawCdpHook = true;
828
938
  Module._load = hookedLoad;
829
939
  }
940
+ /**
941
+ * Patch copies that loaded before this module (e.g. a local run that imports
942
+ * crawlee or playwright before scrapely). Copies loaded later are patched by
943
+ * the loader hooks.
944
+ *
945
+ * @internal Exported for tests.
946
+ */
947
+ function patchLoadedModules() {
948
+ const cache = require.cache ?? {};
949
+ for (const [filename, cached] of Object.entries(cache)) {
950
+ const mod = cached?.exports;
951
+ if (!mod || !cached?.loaded)
952
+ continue;
953
+ try {
954
+ if (CRAWLER_FILENAME_RE.test(filename))
955
+ patchCrawleeCrawlerModule(mod);
956
+ if (BROWSER_POOL_FILENAME_RE.test(filename))
957
+ patchBrowserPoolModule(mod);
958
+ if (LAUNCHER_FILENAME_RE.test(filename))
959
+ patchBrowserLauncherModule(mod, filename);
960
+ if (PLAYWRIGHT_ENTRY_FILENAME_RE.test(filename))
961
+ patchRawPlaywrightModule(mod);
962
+ const puppeteerEntry = PUPPETEER_ENTRY_FILENAME_RE.exec(filename);
963
+ // Existing instance first: once its class is patched, the instance
964
+ // reads as patched but still holds the original bound methods.
965
+ if (puppeteerEntry)
966
+ patchRawPuppeteerModule(mod, puppeteerEntry[1]);
967
+ }
968
+ catch (err) {
969
+ console.warn(`[scrapely] CDP patch failed for '${filename}':`, err?.message);
970
+ }
971
+ }
972
+ for (const [filename, cached] of Object.entries(cache)) {
973
+ if (cached?.loaded && /[/\\]lib[/\\]cjs[/\\]puppeteer[/\\]node[/\\]PuppeteerNode\.js$/.test(filename)) {
974
+ patchPuppeteerNodeClass(cached.exports?.PuppeteerNode);
975
+ }
976
+ }
977
+ }
978
+ // ─── Pure-ESM puppeteer ─────────────────────────────────────────────────────
979
+ //
980
+ // `import puppeteer from 'puppeteer'` loads puppeteer's ESM build, which never
981
+ // passes through Module._load. A module load hook appends one call to
982
+ // PuppeteerNode.js that hands the class to patchPuppeteerNodeClass (through a
983
+ // global, so it also works from Node 20's off-thread loader). The hook is only
984
+ // registered when puppeteer is installed where ESM code can import it (ESM
985
+ // resolution ignores NODE_PATH), so other actors pay nothing.
986
+ /** @internal Global the ESM load hook's appended code calls. */
987
+ exports.PUPPETEER_NODE_PATCH_GLOBAL = 'scrapely.patchPuppeteerNodeClass';
988
+ /** puppeteer-core's ESM PuppeteerNode.js (URL): lib/esm/puppeteer/... up to v24, lib/puppeteer/... (ESM only) from v25. */
989
+ exports.PUPPETEER_NODE_ESM_URL_RE = /\/puppeteer-core\/lib\/(esm\/)?puppeteer\/node\/PuppeteerNode\.js$/;
990
+ /** Appended to the ESM PuppeteerNode.js source; runs right after the class is defined. */
991
+ exports.PUPPETEER_NODE_ESM_SUFFIX = `\n;globalThis[Symbol.for(${JSON.stringify(exports.PUPPETEER_NODE_PATCH_GLOBAL)})]?.(PuppeteerNode);\n`;
992
+ /** Directories ESM code in this process resolves bare imports from: cwd and the main script's ancestors. */
993
+ function esmLookupDirs() {
994
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
995
+ const path = require('path');
996
+ const starts = [process.cwd()];
997
+ if (process.argv[1])
998
+ starts.push(path.dirname(path.resolve(process.argv[1])));
999
+ const dirs = new Set();
1000
+ for (const start of starts) {
1001
+ let dir = start;
1002
+ for (;;) {
1003
+ dirs.add(dir);
1004
+ const parent = path.dirname(dir);
1005
+ if (parent === dir)
1006
+ break;
1007
+ dir = parent;
1008
+ }
1009
+ }
1010
+ return [...dirs];
1011
+ }
1012
+ /** @internal Whether an ESM import of puppeteer / puppeteer-core could succeed in this process. */
1013
+ function esmPuppeteerInstalled() {
1014
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1015
+ const fs = require('fs');
1016
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1017
+ const path = require('path');
1018
+ for (const dir of esmLookupDirs()) {
1019
+ for (const name of ['puppeteer-core', 'puppeteer']) {
1020
+ if (fs.existsSync(path.join(dir, 'node_modules', name, 'package.json')))
1021
+ return true;
1022
+ }
1023
+ }
1024
+ return false;
1025
+ }
1026
+ function installEsmPuppeteerHook() {
1027
+ globalThis[Symbol.for(exports.PUPPETEER_NODE_PATCH_GLOBAL)] = patchPuppeteerNodeClass;
1028
+ if (process.env.SCRAPELY_ESM_PUPPETEER_HOOK === '0' || !esmPuppeteerInstalled())
1029
+ return;
1030
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1031
+ const Module = require('module');
1032
+ if (Module._scrapelyEsmPuppeteerHook)
1033
+ return;
1034
+ Module._scrapelyEsmPuppeteerHook = true;
1035
+ try {
1036
+ if (typeof Module.registerHooks === 'function') {
1037
+ // Node 22.15+ / 23.5+: synchronous, in-thread.
1038
+ Module.registerHooks({
1039
+ load(url, context, nextLoad) {
1040
+ const result = nextLoad(url, context);
1041
+ if (result?.format === 'module' && result.source != null && exports.PUPPETEER_NODE_ESM_URL_RE.test(url)) {
1042
+ const source = typeof result.source === 'string' ? result.source : new TextDecoder().decode(result.source);
1043
+ return { ...result, source: source + exports.PUPPETEER_NODE_ESM_SUFFIX };
1044
+ }
1045
+ return result;
1046
+ },
1047
+ });
1048
+ }
1049
+ else if (typeof Module.register === 'function') {
1050
+ // Node 20.6+ / 22.0-22.14: off-thread loader (esm-loader-hooks.mjs).
1051
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1052
+ const { pathToFileURL } = require('url');
1053
+ Module.register('./esm-loader-hooks.mjs', pathToFileURL(__filename));
1054
+ }
1055
+ }
1056
+ catch (err) {
1057
+ console.warn('[scrapely] ESM puppeteer hook unavailable:', err?.message);
1058
+ }
1059
+ }
830
1060
  function applyRawBrowserPatches() {
831
- patchSdkTreeRawModules();
832
1061
  installRawModuleLoadHook();
833
- patchLoadedCrawleeCopies();
1062
+ installEsmPuppeteerHook();
1063
+ patchLoadedModules();
1064
+ }
1065
+ // Package-manager processes (npm/npx/yarn/pnpm) only spawn the actor; they
1066
+ // never run actor code, so they get no hooks. `npm start` whose script is a
1067
+ // plain `node <file>` runs that node process directly (see node-wrapper.ts).
1068
+ if (!(0, node_wrapper_js_1.handlePackageManagerProcess)()) {
1069
+ applyRawBrowserPatches();
834
1070
  }
835
- applyRawBrowserPatches();
@@ -27,7 +27,7 @@ function getPlatformProxyEnv() {
27
27
  };
28
28
  }
29
29
  const BYOP_WARNING = '[Scrapely] No proxy configured. Scrapely injects platform proxy credentials (APIFY_PROXY_*/SCRAPELY_PROXY_*) into runs when your account has proxy access. ' +
30
- 'Locally, either add proxyUrls to your actor input, or copy your proxy credentials from the Scrapely console (Proxy page) into the ' +
30
+ 'Locally, either add proxyUrls to your actor input, or copy your proxy credentials from the Scrapely dashboard (Proxy page) into the ' +
31
31
  'SCRAPELY_PROXY_HOSTNAME, SCRAPELY_PROXY_PORT, SCRAPELY_PROXY_USERNAME and SCRAPELY_PROXY_PASSWORD environment variables. ' +
32
32
  'Using the platform proxy from outside Scrapely requires an active plan or a prepaid (PAYG) balance of at least $1; ' +
33
33
  'free accounts get run-scoped credentials injected into their on-platform runs only.';
package/package.json CHANGED
@@ -1,9 +1,22 @@
1
1
  {
2
2
  "name": "scrapely",
3
- "version": "1.2.26",
3
+ "version": "1.2.27",
4
4
  "description": "Scrapely SDK for building JavaScript and TypeScript Actors",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.mjs",
11
+ "require": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./package.json": "./package.json",
15
+ "./dist/*.js": "./dist/*.js",
16
+ "./dist/*.mjs": "./dist/*.mjs",
17
+ "./dist/*": "./dist/*.js",
18
+ "./*": "./*"
19
+ },
7
20
  "files": [
8
21
  "dist",
9
22
  "LICENSE",
@@ -48,7 +61,8 @@
48
61
  "test:cdp-retry": "npx tsx --test tests/cdp-retry.test.ts",
49
62
  "test:proxy-input-aliases": "npx tsx --test tests/proxy-input-aliases.test.ts",
50
63
  "test:charge-retries": "npx tsx --test tests/charge-retries.test.ts",
51
- "test:solver": "npx tsx --test tests/solver.test.ts"
64
+ "test:solver": "npx tsx --test tests/solver.test.ts",
65
+ "test:lazy-preload": "npx tsx --test tests/lazy-preload.test.ts"
52
66
  },
53
67
  "author": "SandeepDev1",
54
68
  "license": "ISC AND Apache-2.0",