/**
* Expose a function to the page if it does not exist
*
* NOTE:
* Rewrite it to 'upsertFunction' after updating Puppeteer to 20.6 or higher
* using page.removeExposedFunction
* https://pptr.dev/api/puppeteer.page.removeexposedfunction
*
* @param {object} page - Puppeteer Page instance
* @param {string} name
* @param {Function} fn
*/
const routedFunctionsByPage = new WeakMap();
/**
* Returns the known Puppeteer browser connection state across supported
* releases. Unknown embedders intentionally return undefined so cleanup code
* can preserve its conservative fallback.
* Puppeteer 25 replaced Browser.isConnected() with the Browser.connected
* getter, while older releases and embedders can still expose the method.
*
* @param {object} browser - Puppeteer Browser instance
* @returns {boolean|undefined}
*/
function getBrowserConnectionState(browser) {
if (!browser) return undefined;
if (typeof browser.isConnected === 'function') {
return browser.isConnected() === true;
}
if (typeof browser.connected === 'boolean') return browser.connected;
return undefined;
}
const isBrowserConnected = (browser) =>
getBrowserConnectionState(browser) === true;
const getRoutedFunctions = (page) => {
let routedFunctions = routedFunctionsByPage.get(page);
if (!routedFunctions) {
routedFunctions = new Map();
routedFunctionsByPage.set(page, routedFunctions);
}
return routedFunctions;
};
const hasExposedFunction = (page, name) =>
page.evaluate((bindingName) => {
return !!window[bindingName];
}, name);
const isDuplicateBindingError = (error, name) => {
const message = error instanceof Error ? error.message : '';
return (
message.includes('Failed to add page binding') &&
(message.includes(`window['${name}'] already exists`) ||
message.includes(`window["${name}"] already exists`))
);
};
/**
* Install a stable Puppeteer binding while the Page target is known to be
* quiescent. A later exposeFunctionIfAbsent call only replaces the in-process
* handler and never calls Page.addScriptToEvaluateOnNewDocument again.
*
* @param {object} page - Puppeteer Page instance
* @param {string} name
* @returns {Promise<boolean>} whether this process owns the routed binding
*/
async function prepareRoutedFunction(page, name) {
const routedFunctions = getRoutedFunctions(page);
const current = routedFunctions.get(name);
if (current) {
const owned = await current.ready;
return owned && routedFunctions.get(name) === current;
}
const route = { handler: undefined, ready: undefined };
route.ready = (async () => {
if (await hasExposedFunction(page, name)) {
return false;
}
try {
await page.exposeFunction(name, (...args) => {
return route.handler?.(...args);
});
return true;
} catch (error) {
if (
!isDuplicateBindingError(error, name) ||
!(await hasExposedFunction(page, name))
) {
throw error;
}
return false;
}
})();
routedFunctions.set(name, route);
try {
const owned = await route.ready;
if (!owned && routedFunctions.get(name) === route) {
routedFunctions.delete(name);
}
return owned;
} catch (error) {
if (routedFunctions.get(name) === route) {
routedFunctions.delete(name);
}
throw error;
}
}
async function exposeFunctionIfAbsent(page, name, fn) {
const routedFunctions = routedFunctionsByPage.get(page);
const route = routedFunctions?.get(name);
if (route) {
const owned = await route.ready;
if (owned && routedFunctions.get(name) === route) {
route.handler = fn;
return;
}
}
const exist = await hasExposedFunction(page, name);
if (exist) {
return;
}
try {
await page.exposeFunction(name, fn);
} catch (error) {
if (
!isDuplicateBindingError(error, name) ||
!(await hasExposedFunction(page, name))
) {
throw error;
}
}
}
module.exports = {
exposeFunctionIfAbsent,
getBrowserConnectionState,
isBrowserConnected,
prepareRoutedFunction,
};