navflow-browser-server
Version:
Standalone Playwright browser server for NavFlow - enables browser automation with API key authentication, workspace device management, session sync, and requires Node.js v22+
831 lines (766 loc) β’ 33.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.HumanLoopBannerService = void 0;
const events_1 = require("events");
class HumanLoopBannerService extends events_1.EventEmitter {
constructor() {
super(...arguments);
this.activeSessions = new Map();
this.timeoutHandlers = new Map();
}
/**
* Show the human-in-the-loop banner on a page
*/
async showBanner(sessionId, nodeId, page, prompt, timeout = 300, deviceApiKey, proxyServerUrl) {
const sessionKey = `${sessionId}_${nodeId}`;
// Clean up any existing session
await this.hideBanner(sessionKey);
const session = {
nodeId,
sessionId,
prompt,
timeout,
startTime: new Date(),
isActive: true,
page
};
this.activeSessions.set(sessionKey, session);
// Wait for page to stabilize before injecting banner
await page.waitForTimeout(2000);
// Inject the banner into the page
await this.injectBanner(page, sessionKey, prompt, timeout, deviceApiKey, proxyServerUrl);
// Monitor and re-inject banner if it gets removed
await this.startBannerMonitoring(page, sessionKey, prompt, timeout, deviceApiKey, proxyServerUrl);
// Set up page event listener for banner actions
const handlePageEvent = async () => {
try {
const action = await page.evaluate('window.navflowBannerAction');
if (action && action.sessionKey === sessionKey) {
console.log('π― Detected banner action:', action);
// Clear the action to prevent duplicate processing
await page.evaluate('window.navflowBannerAction = null');
// Handle the action
if (action.type === 'extend') {
await this.handleUserResponse(sessionKey, 'extend', undefined, action.extensionTime);
}
else if (action.type === 'complete') {
await this.handleUserResponse(sessionKey, 'complete', action.userResponse);
}
else if (action.type === 'reset') {
await this.handleUserResponse(sessionKey, 'reset', undefined, undefined, action.resetTimeout);
}
}
}
catch (error) {
console.error('Error handling page event:', error);
}
};
// Poll for banner actions every 100ms
const pollInterval = setInterval(handlePageEvent, 100);
session.pollInterval = pollInterval;
// Set up the timeout handler
const timeoutHandler = setTimeout(() => {
clearInterval(pollInterval);
this.handleTimeout(sessionKey);
}, timeout * 1000);
this.timeoutHandlers.set(sessionKey, timeoutHandler);
// Return a promise that resolves when user interacts
return new Promise((resolve) => {
this.once(`response_${sessionKey}`, (response) => {
clearInterval(pollInterval);
resolve(response);
});
});
}
/**
* Hide the banner from a page
*/
async hideBanner(sessionKey) {
const session = this.activeSessions.get(sessionKey);
if (!session)
return;
try {
// Remove banner and clean up monitoring
await session.page.evaluate(`
(() => {
const banner = document.getElementById('navflow-human-loop-banner');
if (banner) {
banner.remove();
}
// Clean up monitoring
if (window.navflowMonitorInterval) {
clearInterval(window.navflowMonitorInterval);
window.navflowMonitorInterval = null;
}
if (window.navflowBannerObserver) {
window.navflowBannerObserver.disconnect();
window.navflowBannerObserver = null;
}
if (window.navflowTimerInterval) {
clearInterval(window.navflowTimerInterval);
window.navflowTimerInterval = null;
}
// Restore original history methods if they were overridden
if (window.originalPushState) {
history.pushState = window.originalPushState;
window.originalPushState = null;
}
if (window.originalReplaceState) {
history.replaceState = window.originalReplaceState;
window.originalReplaceState = null;
}
// Clean up session data and functions
window.navflowSession = null;
window.navflowBannerAction = null;
window.navflowInjectBanner = null;
window.navflowReinjecting = null;
window.navflowInjecting = null;
})()
`);
}
catch (error) {
console.warn('Failed to remove banner from page:', error);
}
// Clear timeout
const timeoutHandler = this.timeoutHandlers.get(sessionKey);
if (timeoutHandler) {
clearTimeout(timeoutHandler);
this.timeoutHandlers.delete(sessionKey);
}
// Clear poll interval
if (session.pollInterval) {
clearInterval(session.pollInterval);
}
// Clean up session
this.activeSessions.delete(sessionKey);
}
/**
* Handle user response from the banner
*/
async handleUserResponse(sessionKey, action, userResponse, extensionTime, resetTimeout) {
const session = this.activeSessions.get(sessionKey);
if (!session || !session.isActive)
return;
session.isActive = false;
const response = {
action,
userResponse,
extensionTime,
resetTimeout
};
if (action === 'extend' && extensionTime) {
// Extend the timeout
session.timeout += extensionTime;
session.isActive = true;
// Clear old timeout and set new one
const oldHandler = this.timeoutHandlers.get(sessionKey);
if (oldHandler) {
clearTimeout(oldHandler);
}
// Clear old poll interval and start new one (if it exists)
if (session.pollInterval) {
clearInterval(session.pollInterval);
}
// Restart polling for this session
const handlePageEvent = async () => {
try {
const action = await session.page.evaluate('window.navflowBannerAction');
if (action && action.sessionKey === sessionKey) {
console.log('π― Detected banner action after extension:', action);
// Clear the action to prevent duplicate processing
await session.page.evaluate('window.navflowBannerAction = null');
// Handle the action
if (action.type === 'extend') {
await this.handleUserResponse(sessionKey, 'extend', undefined, action.extensionTime);
}
else if (action.type === 'complete') {
await this.handleUserResponse(sessionKey, 'complete', action.userResponse);
}
else if (action.type === 'reset') {
await this.handleUserResponse(sessionKey, 'reset', undefined, undefined, action.resetTimeout);
}
}
}
catch (error) {
console.error('Error handling page event after extension:', error);
}
};
const newPollInterval = setInterval(handlePageEvent, 100);
session.pollInterval = newPollInterval;
const newHandler = setTimeout(() => {
clearInterval(newPollInterval);
this.handleTimeout(sessionKey);
}, extensionTime * 1000);
this.timeoutHandlers.set(sessionKey, newHandler);
// Update banner with new time
await this.updateBannerTime(session.page, sessionKey, session.timeout);
return;
}
if (action === 'reset' && resetTimeout) {
// Reset the timeout to original value
session.timeout = resetTimeout;
session.startTime = new Date();
session.isActive = true;
// Clear old timeout and set new one
const oldHandler = this.timeoutHandlers.get(sessionKey);
if (oldHandler) {
clearTimeout(oldHandler);
}
// Clear old poll interval and start new one (if it exists)
if (session.pollInterval) {
clearInterval(session.pollInterval);
}
// Restart polling for this session
const handlePageEvent = async () => {
try {
const action = await session.page.evaluate('window.navflowBannerAction');
if (action && action.sessionKey === sessionKey) {
console.log('π― Detected banner action after reset:', action);
// Clear the action to prevent duplicate processing
await session.page.evaluate('window.navflowBannerAction = null');
// Handle the action
if (action.type === 'extend') {
await this.handleUserResponse(sessionKey, 'extend', undefined, action.extensionTime);
}
else if (action.type === 'complete') {
await this.handleUserResponse(sessionKey, 'complete', action.userResponse);
}
else if (action.type === 'reset') {
await this.handleUserResponse(sessionKey, 'reset', undefined, undefined, action.resetTimeout);
}
else if (action.type === 'reset') {
await this.handleUserResponse(sessionKey, 'reset', undefined, undefined, action.resetTimeout);
}
}
}
catch (error) {
console.error('Error handling page event after reset:', error);
}
};
const newPollInterval = setInterval(handlePageEvent, 100);
session.pollInterval = newPollInterval;
const newHandler = setTimeout(() => {
clearInterval(newPollInterval);
this.handleTimeout(sessionKey);
}, resetTimeout * 1000);
this.timeoutHandlers.set(sessionKey, newHandler);
// Update banner with reset time
await this.updateBannerTime(session.page, sessionKey, session.timeout, true);
return;
}
// Hide banner and emit response
await this.hideBanner(sessionKey);
this.emit(`response_${sessionKey}`, response);
}
/**
* Handle timeout
*/
handleTimeout(sessionKey) {
const session = this.activeSessions.get(sessionKey);
if (!session)
return;
session.isActive = false;
this.hideBanner(sessionKey);
this.emit(`response_${sessionKey}`, { action: 'timeout' });
}
/**
* Inject the banner HTML/CSS/JS into the page
*/
async injectBanner(page, sessionKey, prompt, timeout, deviceApiKey, proxyServerUrl) {
// Use proxy-server URL if provided, otherwise fallback to browser-server
const serverPort = process.env.BROWSER_SERVER_PORT || 3002;
const fallbackUrl = `http://localhost:${serverPort}`;
const serverUrl = proxyServerUrl || fallbackUrl;
// Inject banner using a function string to avoid TypeScript DOM issues
const bannerScript = `
(() => {
const sessionKey = '${sessionKey}';
const prompt = '${prompt.replace(/'/g, "\\'")}';
const timeout = ${timeout};
const serverUrl = '${serverUrl}';
const deviceApiKey = '${deviceApiKey || ''}';
// Remove existing banner if any
const existingBanner = document.getElementById('navflow-human-loop-banner');
if (existingBanner) {
existingBanner.remove();
}
// Create banner HTML
const bannerHTML = \`
<div id="navflow-human-loop-banner" style="
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999999;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
border-bottom: 3px solid #4c51bf;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
user-select: none;
">
<div style="display: flex; align-items: center; justify-content: space-between; max-width: 1200px; margin: 0 auto;">
<div style="display: flex; align-items: center; gap: 12px; flex: 1;">
<div style="
width: 32px;
height: 32px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
">βΈοΈ</div>
<div>
<div style="font-weight: 600; margin-bottom: 4px;">Automation Paused - Human Input Required</div>
<div style="opacity: 0.9; font-size: 13px;">\${prompt}</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 16px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 12px; opacity: 0.8;">Time remaining:</span>
<span id="navflow-timer" style="
font-weight: 700;
font-size: 16px;
min-width: 50px;
text-align: center;
">\${Math.floor(timeout / 60)}:\${(timeout % 60).toString().padStart(2, '0')}</span>
</div>
<div style="display: flex; gap: 8px;">
<button id="navflow-reset-btn" style="
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
color: white;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
">Reset Timer</button>
<button id="navflow-complete-btn" style="
background: #48bb78;
border: 1px solid #38a169;
color: white;
padding: 6px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
">Complete</button>
</div>
</div>
</div>
</div>
\`;
// Insert banner at the top of the body
document.body.insertAdjacentHTML('afterbegin', bannerHTML);
// Wait a moment for DOM to update, then add event listeners
setTimeout(() => {
console.log('Setting up banner event listeners...');
const resetBtn = document.getElementById('navflow-reset-btn');
const completeBtn = document.getElementById('navflow-complete-btn');
console.log('Found buttons:', {
resetBtn: !!resetBtn,
completeBtn: !!completeBtn
});
if (resetBtn) {
// Hover effects
resetBtn.addEventListener('mouseenter', () => {
resetBtn.style.background = 'rgba(255,255,255,0.3)';
});
resetBtn.addEventListener('mouseleave', () => {
resetBtn.style.background = 'rgba(255,255,255,0.2)';
});
// Click handler
resetBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('π Reset timer button clicked!', { sessionKey });
// Store the action for Playwright to detect
window.navflowBannerAction = {
type: 'reset',
sessionKey: sessionKey,
resetTimeout: timeout,
timestamp: Date.now()
};
// Dispatch custom event that Playwright can listen for
window.dispatchEvent(new CustomEvent('navflow-banner-action', {
detail: window.navflowBannerAction
}));
});
}
if (completeBtn) {
// Hover effects
completeBtn.addEventListener('mouseenter', () => {
completeBtn.style.background = '#38a169';
});
completeBtn.addEventListener('mouseleave', () => {
completeBtn.style.background = '#48bb78';
});
// Click handler
completeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('β
Complete button clicked!', { sessionKey });
// Store the action for Playwright to detect
window.navflowBannerAction = {
type: 'complete',
sessionKey: sessionKey,
userResponse: 'User completed task',
timestamp: Date.now()
};
// Dispatch custom event that Playwright can listen for
window.dispatchEvent(new CustomEvent('navflow-banner-action', {
detail: window.navflowBannerAction
}));
});
}
}, 100);
// Store session info and start timer
window.navflowSession = { sessionKey, timeout, startTime: Date.now() };
// Start countdown timer
const timer = document.getElementById('navflow-timer');
const startTime = Date.now();
const updateTimer = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const remaining = Math.max(0, timeout - elapsed);
if (remaining <= 0) {
if (timer) timer.textContent = '0:00';
return;
}
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
if (timer) {
timer.textContent = \`\${minutes}:\${seconds.toString().padStart(2, '0')}\`;
}
};
updateTimer();
const timerInterval = setInterval(updateTimer, 1000);
window.navflowTimerInterval = timerInterval;
})();
`;
await page.evaluate(bannerScript);
}
/**
* Update the timer display in the banner
*/
async updateBannerTime(page, sessionKey, newTimeout, isReset = false) {
const updateScript = `
(() => {
const newTimeout = ${newTimeout};
// Update session timeout
if (window.navflowSession) {
window.navflowSession.timeout = newTimeout;
window.navflowSession.startTime = Date.now();
}
// Show feedback
const banner = document.getElementById('navflow-human-loop-banner');
if (banner) {
if (${isReset}) {
// Flash blue to indicate reset
banner.style.background = 'linear-gradient(135deg, #4299e1 0%, #3182ce 100%)';
} else {
// Flash green to indicate extension
banner.style.background = 'linear-gradient(135deg, #48bb78 0%, #38a169 100%)';
}
setTimeout(() => {
banner.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
}, 1000);
}
})();
`;
await page.evaluate(updateScript);
}
/**
* Get active sessions
*/
getActiveSessions() {
return Array.from(this.activeSessions.keys());
}
/**
* Monitor the page and re-inject banner if it gets removed
*/
async startBannerMonitoring(page, sessionKey, prompt, timeout, deviceApiKey, proxyServerUrl) {
const session = this.activeSessions.get(sessionKey);
if (!session)
return;
const monitorScript = `
(() => {
const sessionKey = '${sessionKey}';
const prompt = '${prompt.replace(/'/g, "\\'")}';
const timeout = ${timeout};
const serverUrl = '${proxyServerUrl || `http://localhost:${process.env.BROWSER_SERVER_PORT || 3002}`}';
const deviceApiKey = '${deviceApiKey || ''}';
// Store original inject function
window.navflowInjectBanner = ${this.getBannerInjectionScript()};
// Enhanced banner monitoring with persistence and duplicate prevention
const ensureBannerPresence = () => {
const existingBanner = document.getElementById('navflow-human-loop-banner');
const hasActiveSession = window.navflowSession && window.navflowSession.sessionKey === sessionKey;
// Only proceed if we have an active session
if (!hasActiveSession) {
return;
}
// If banner exists and belongs to current session, do nothing
if (existingBanner) {
return;
}
// Prevent multiple simultaneous re-injections
if (window.navflowReinjecting) {
return;
}
console.log('β οΈ Banner missing, re-injecting with current state...');
window.navflowReinjecting = true;
try {
// Calculate remaining time based on original start time
const originalTimeout = window.navflowSession.timeout;
const startTime = window.navflowSession.startTime;
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const remainingTimeout = Math.max(0, originalTimeout - elapsed);
if (remainingTimeout > 0) {
// Double-check no banner was created while we were calculating
const doubleCheckBanner = document.getElementById('navflow-human-loop-banner');
if (!doubleCheckBanner) {
window.navflowInjectBanner(sessionKey, prompt, remainingTimeout, serverUrl, deviceApiKey);
}
}
} finally {
// Clear the re-injection flag after a short delay
setTimeout(() => {
window.navflowReinjecting = false;
}, 1000);
}
};
// Check every 2 seconds to prevent race conditions
const monitorInterval = setInterval(ensureBannerPresence, 2000);
window.navflowMonitorInterval = monitorInterval;
// Enhanced DOM mutation observer
const observer = new MutationObserver((mutations) => {
let shouldReinject = false;
mutations.forEach((mutation) => {
// Check for removed nodes
if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
mutation.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the banner itself was removed
if (node.id === 'navflow-human-loop-banner') {
shouldReinject = true;
}
// Check if a parent containing the banner was removed
if (node.querySelector && node.querySelector('#navflow-human-loop-banner')) {
shouldReinject = true;
}
}
});
}
// Check for attribute changes that might hide the banner
if (mutation.type === 'attributes' && mutation.target && mutation.target.id === 'navflow-human-loop-banner') {
const target = mutation.target;
const style = window.getComputedStyle(target);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
shouldReinject = true;
}
}
});
if (shouldReinject) {
console.log('π Banner removed/hidden via DOM changes, scheduling re-injection...');
// Longer delay to prevent rapid re-injections
setTimeout(ensureBannerPresence, 1000);
}
});
// Monitor the entire document for comprehensive detection
observer.observe(document, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class', 'hidden']
});
window.navflowBannerObserver = observer;
// Monitor for page navigation events that might remove the banner
if (!window.originalPushState) {
window.originalPushState = history.pushState;
window.originalReplaceState = history.replaceState;
history.pushState = function(...args) {
window.originalPushState.apply(history, args);
setTimeout(ensureBannerPresence, 200);
};
history.replaceState = function(...args) {
window.originalReplaceState.apply(history, args);
setTimeout(ensureBannerPresence, 200);
};
}
// Monitor for popstate events (back/forward navigation)
window.addEventListener('popstate', () => {
setTimeout(ensureBannerPresence, 200);
});
// Monitor for page visibility changes
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
setTimeout(ensureBannerPresence, 100);
}
});
// Monitor for hashchange events
window.addEventListener('hashchange', () => {
setTimeout(ensureBannerPresence, 100);
});
// Monitor for focus events
window.addEventListener('focus', () => {
setTimeout(ensureBannerPresence, 100);
});
})();
`;
await page.evaluate(monitorScript);
}
/**
* Get the banner injection script as a string
*/
getBannerInjectionScript() {
return `function(sessionKey, prompt, timeout, serverUrl, deviceApiKey) {
// Prevent duplicate injections
if (window.navflowInjecting) {
console.log('β οΈ Banner injection already in progress, skipping...');
return;
}
window.navflowInjecting = true;
try {
// Remove any existing banners (including duplicates)
const existingBanners = document.querySelectorAll('#navflow-human-loop-banner, [id^="navflow-human-loop-banner"]');
existingBanners.forEach(banner => banner.remove());
// Also remove any banners that might not have the exact ID
const possibleBanners = document.querySelectorAll('[id*="navflow"], [class*="navflow"]');
possibleBanners.forEach(banner => {
if (banner.textContent && banner.textContent.includes('Automation Paused')) {
banner.remove();
}
});
// Create banner HTML
const bannerHTML = \`
<div id="navflow-human-loop-banner" style="
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999999;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
border-bottom: 3px solid #4c51bf;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
user-select: none;
">
<div style="display: flex; align-items: center; justify-content: space-between; max-width: 1200px; margin: 0 auto;">
<div style="display: flex; align-items: center; gap: 12px; flex: 1;">
<div style="
width: 32px;
height: 32px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
">βΈοΈ</div>
<div>
<div style="font-weight: 600; margin-bottom: 4px;">Automation Paused - Human Input Required</div>
<div style="opacity: 0.9; font-size: 13px;">\${prompt}</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 16px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 12px; opacity: 0.8;">Time remaining:</span>
<span id="navflow-timer" style="
font-weight: 700;
font-size: 16px;
min-width: 50px;
text-align: center;
">\${Math.floor(timeout / 60)}:\${(timeout % 60).toString().padStart(2, '0')}</span>
</div>
<div style="display: flex; gap: 8px;">
<button id="navflow-reset-btn" style="
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
color: white;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
">Reset Timer</button>
<button id="navflow-complete-btn" style="
background: #48bb78;
border: 1px solid #38a169;
color: white;
padding: 6px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
">Complete</button>
</div>
</div>
</div>
</div>
\`;
// Insert banner at the top of the body
document.body.insertAdjacentHTML('afterbegin', bannerHTML);
// Add event listeners
setTimeout(() => {
const resetBtn = document.getElementById('navflow-reset-btn');
const completeBtn = document.getElementById('navflow-complete-btn');
if (resetBtn) {
resetBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
window.navflowBannerAction = {
type: 'reset',
sessionKey: sessionKey,
resetTimeout: timeout,
timestamp: Date.now()
};
window.dispatchEvent(new CustomEvent('navflow-banner-action', {
detail: window.navflowBannerAction
}));
});
}
if (completeBtn) {
completeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
window.navflowBannerAction = {
type: 'complete',
sessionKey: sessionKey,
userResponse: 'User completed task',
timestamp: Date.now()
};
window.dispatchEvent(new CustomEvent('navflow-banner-action', {
detail: window.navflowBannerAction
}));
});
}
}, 100);
} finally {
// Clear the injection flag
setTimeout(() => {
window.navflowInjecting = false;
}, 500);
}
}`;
}
/**
* Clean up all sessions
*/
async cleanup() {
const sessionKeys = Array.from(this.activeSessions.keys());
await Promise.all(sessionKeys.map(key => this.hideBanner(key)));
}
}
exports.HumanLoopBannerService = HumanLoopBannerService;
//# sourceMappingURL=HumanLoopBannerService.js.map