Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

exposeFunction: globalThis[(prefix + name)] is not a function #69

Open
big-camel opened this issue Nov 29, 2024 · 8 comments
Open

exposeFunction: globalThis[(prefix + name)] is not a function #69

big-camel opened this issue Nov 29, 2024 · 8 comments
Labels
help wanted Extra attention is needed wontfix This will not be worked on

Comments

@big-camel
Copy link

An error occurred in the browser when using exposeFunction, and the server could not retrieve the value.

page.evaluateOnNewDocument(`document.addEventListener('DOMContentLoaded',  () => window.reportSnapshot());`)
await page.exposeFunction('reportSnapshot', async () => {
    console.log("server")
  })

page error:

Uncaught TypeError: globalThis[(prefix + name)] is not a function
    at reportSnapshot (<anonymous>:13:32)

related code:

(function addPageBinding(type, name, prefix) {
  if (globalThis[name]) {
    return;
  }
  Object.assign(globalThis, {
    [name](...args) {
      const callPuppeteer = globalThis[name];
      callPuppeteer.args ??= /* @__PURE__ */ new Map();
      callPuppeteer.callbacks ??= /* @__PURE__ */ new Map();
      const seq = (callPuppeteer.lastSeq ?? 0) + 1;
      callPuppeteer.lastSeq = seq;
      callPuppeteer.args.set(seq, args);
     // is here
      globalThis[prefix + name](
        JSON.stringify({
          type,
          name,
          seq,
          args,
          isTrivial: !args.some((value) => {
            return value instanceof Node;
          })
        })
      );
      return new Promise((resolve, reject) => {
        callPuppeteer.callbacks.set(seq, {
          resolve(value) {
            callPuppeteer.args.delete(seq);
            resolve(value);
          },
          reject(value) {
            callPuppeteer.args.delete(seq);
            reject(value);
          }
        });
      });
    }
  });
})("exposedFun","reportSnapshot","puppteer")
@big-camel
Copy link
Author

The method corresponding to globalThis[prefix + name] should be this:

async #onBindingCalled(
    event: Protocol.Runtime.BindingCalledEvent,
  ): Promise<void> {
    if (event.executionContextId !== this.#id) {
      return;
    }

    let payload: BindingPayload;
    try {
      payload = JSON.parse(event.payload);
    } catch {
      // The binding was either called by something in the page or it was
      // called before our wrapper was initialized.
      return;
    }
    const { type, name, seq, args, isTrivial } = payload;
    if (type !== 'internal') {
      this.emit('bindingcalled', event);
      return;
    }
    if (!this.#bindings.has(name)) {
      this.emit('bindingcalled', event);
      return;
    }

    try {
      const binding = this.#bindings.get(name);
      await binding?.run(this, seq, args, isTrivial);
    } catch (err) {
      debugError(err);
    }
  }

@big-camel
Copy link
Author

big-camel commented Nov 29, 2024

Added an addExposedFunctionBindingWithEvent method to handle this, available for now.

  #bindings = new Set<string>();

  @throwIfDetached
  async addExposedFunctionBindingWithEvent(binding: Binding): Promise<void> {
    const eventName = CDP_BINDING_PREFIX + binding.name;
    // Add a flag to control the loop
    const flagName = `${eventName}_running`;

    await this.#client.send('Page.addScriptToEvaluateOnNewDocument', {
      source: `
    let ${eventName}_callbacks = [];
    window['${flagName}'] = true;
    window['${eventName}'] = (...args) => {
      for(const callback of ${eventName}_callbacks) {
        callback(...args);
      }
      ${eventName}_callbacks = [];
    };
    window['get_${eventName}'] = async function() {
      return new Promise((resolve, reject) => {
        ${eventName}_callbacks.push(resolve);
      });
    };
    `,
      runImmediately: true,
    });
    this.#bindings.add(binding.name)
    // Start the polling loop with the flag
    void (async () => {
      while (true) {
        try {
          const mainWorld = this.worlds[MAIN_WORLD];
          if (!mainWorld.context || mainWorld.disposed || !this.#bindings.has(binding.name)) {
            break;
          }

          const { exceptionDetails, result: remoteObject } = await this.#client.send('Runtime.evaluate', {
            expression: `window['get_${eventName}']()`,
            awaitPromise: true
          });

          if (exceptionDetails) {
            throw new Error(exceptionDetails.text);
          }

          if (!remoteObject.value) continue;
          mainWorld.emitter.emit('bindingcalled', {
            name: binding.name,
            payload: remoteObject.value,
            executionContextId: mainWorld.context.id
          });
        } catch (e) {
          debugError(e)
          // Add a small delay to prevent tight-loop CPU usage on errors
          await new Promise(resolve => setTimeout(resolve, 10));
        }
      }
    })();
  }

  @throwIfDetached
  async addExposedFunctionBinding(binding: Binding): Promise<void> {
    // If a frame has not started loading, it might never start. Rely on
    // addScriptToEvaluateOnNewDocument in that case.
    if (this !== this._frameManager.mainFrame() && !this._hasStartedLoading) {
      return;
    }
    if (process.env['REBROWSER_PATCHES_RUNTIME_FIX_MODE'] !== '0') {
      this.addExposedFunctionBindingWithEvent(binding)
    }
    await Promise.all([
      this.#client.send('Runtime.addBinding', {
        name: CDP_BINDING_PREFIX + binding.name,
      }),
      this.evaluate(binding.initSource).catch(debugError),
    ]);
  }

  @throwIfDetached
  async removeExposedFunctionBinding(binding: Binding): Promise<void> {
    ...
    if (process.env['REBROWSER_PATCHES_RUNTIME_FIX_MODE'] !== '0' && this.#bindings.has(binding.name)) {
      this.#bindings.delete(binding.name)
    }
    ...
  }

@nwebson
Copy link
Contributor

nwebson commented Dec 4, 2024

I'm really confused with this issue. I cannot reproduce it. Are you sure that it's relevant to the patches?

page.exposeFunction is called after page.evaluateOnNewDocument? This way DOMContentLoaded probably will be fired before page.exposeFunction executed leading to window.reportSnapshot is undefined error.

Could you please share the full code example?

@big-camel
Copy link
Author

I'm really confused with this issue. I cannot reproduce it. Are you sure that it's relevant to the patches?

page.exposeFunction is called after page.evaluateOnNewDocument? This way DOMContentLoaded probably will be fired before page.exposeFunction executed leading to window.reportSnapshot is undefined error.

Could you please share the full code example?

I tried disabling the patches, and it works fine. However, when the patches are enabled, the issue occurs.

The problem is not that window.reportSnapshot is undefined, because puppeteer defines reportSnapshot on the window object through Object.assign(globalThis, {[name](...args)}), so it is accessible.

The issue lies in the fact that the method inside Object.assign(globalThis, {[name](...args)}), specifically globalThis[prefix + name], is not defined.

By debugging the puppeteer source code, the issue seems to be in the addExposedFunctionBinding method:

this.#client.send('Runtime.addBinding', {
  name: CDP_BINDING_PREFIX + binding.name,
})

Therefore, the full method name for globalThis[prefix + name] should be puppeteer_reportSnapshot, but window.puppeteer_reportSnapshot is undefined.

Below is my simplified reproduction code:

import fs from 'fs';
import type { Browser } from "puppeteer-core";
import puppeteerExtra from "puppeteer-extra";

const findSystemChrome = () => {
  // linux or windows
  const chromePaths = [
    '/usr/bin/google-chrome',
    'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
    'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
  ];
  return chromePaths.find((path) => fs.existsSync(path));
}

// primary logic
const args = [
    '--no-sandbox',
    '--disable-client-side-phishing-detection',
    '--disable-default-apps',
    '--disable-hang-monitor',
    '--metrics-recording-only',
    '--disable-sync',
    '--safebrowsing-disable-auto-update',
    '--disable-breakpad',
    '--disable-extensions',
    '--disable-blink-features=AutomationControlled'
];
const headless = false // or true
if (headless) {
  args.push('--disable-gpu');
  args.push('--disable-dev-shm-usage');
  args.push('--no-first-run');
}

const browser: Browser = await puppeteerExtra.launch({
  executablePath: findSystemChrome(),
  ignoreDefaultArgs: ['--enable-automation'],
  args,
  headless,
  timeout: 30_000,
}).catch((err: any) => {
  console.error(`Unknown firebase issue, just die fast.`, { err });
  return Promise.reject(err);
});

const page = await browser.newPage();

const preparations = [
    page.exposeFunction('reportSnapshot', async () => {
      console.log("server")
    }),
    page.evaluateOnNewDocument(`document.addEventListener('DOMContentLoaded',  () => window.reportSnapshot());`)
]

await Promise.all(preparations);

await = page.goto("https://www.google.com", {
  waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
  timeout: 30_000
})

@nwebson
Copy link
Contributor

nwebson commented Dec 5, 2024

Thanks for such a detailed report. The issue is that without having Runtime.enable, all added bindings will be cleared after the page is refreshed.

I consider this behavior a Chrome bug, as the CDP documentation clearly states:

adds binding with the given name on the global objects of all inspected contexts, including those created later, bindings survive reloads

I just filed a bug report with Chromium: https://issues.chromium.org/issues/382473087

Please vote +1 to get it fixed sooner (though this may not help).

I don't think it's feasible to try to fix it with CDP - all solutions I can think of are quite hacky and won't guarantee that bindings will be enabled before Page.addScriptToEvaluateOnNewDocument.

Your solution with polling totally makes sense, thanks for sharing this. That could be used until the bug is fixed, but I won't add this to this repo's codebase as I consider this bug out of scope of this project.

P.S. Actually, it might be possible to "fix" it by using Debugger.enable and Debugger.setBreakpointByUrl with lineNumber: 0, but not quite sure. Just a tip for somebody who really needs to work on this issue.

@sgtghost
Copy link

Just curious if anyone else has encountered the same issue related to the missing binding. It is quite consistent with my project as most of our client sites don't allow any page refresh but reload. The rebrowser patch can help you get through the cloudflare challenge but it will cause a hang-up in such cases.

@fred8617
Copy link

had same issue, want to expose a function to log sth to the node process, but it's not worked, and page.on('console') is also not work for the patch

Just curious if anyone else has encountered the same issue related to the missing binding. It is quite consistent with my project as most of our client sites don't allow any page refresh but reload. The rebrowser patch can help you get through the cloudflare challenge but it will cause a hang-up in such cases.

@fred8617
Copy link

fred8617 commented Feb 1, 2025

using a very tricky way to solve the issue
use page.evaluateOnNewDocument to mock the exposeFunction

await page.evaluateOnNewDocument(() => {
 window.yourExposeFunc = (param) => {
          fetch('https://specifiy-a-url.com', {
            body: JSON.stringify(param),
            method: 'POST',
          }).catch(() => {
           
          });
        };
})

then use the event listener

  page.on('request', (req) => {
        if (req.url().includes('specifiy-a-url')) {
          doSthInNode(JSON.parse(req.postData() || '{}'));
          req.abort('aborted', 1);
        }
      });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Extra attention is needed wontfix This will not be worked on
Projects
None yet
Development

No branches or pull requests

4 participants