A universal browser automation library that supports both Playwright and Puppeteer with a unified API. The key focus is on stoppable page triggers - ensuring automation logic is properly mounted/unmounted during page navigation.
npm install browser-commanderYou'll also need either Playwright or Puppeteer:
# With Playwright
npm install playwright
# Or with Puppeteer
npm install puppeteerBrowser Commander manages the browser as a state machine with two states:
+------------------+ +------------------+
| | navigation start | |
| WORKING STATE | -------------------> | LOADING STATE |
| (action runs) | | (wait only) |
| | <----------------- | |
+------------------+ page ready +------------------+
LOADING STATE: Page is loading. Only waiting/tracking operations are allowed. No automation logic runs.
WORKING STATE: Page is fully loaded (30 seconds of network idle). Page triggers can safely interact with DOM.
import { launchBrowser, makeBrowserCommander, makeUrlCondition } from 'browser-commander';
// 1. Launch browser
const { browser, page } = await launchBrowser({ engine: 'playwright' });
// 2. Create commander
const commander = makeBrowserCommander({ page, verbose: true });
// 3. Register page trigger with condition and action
commander.pageTrigger({
name: 'example-trigger',
condition: makeUrlCondition('*example.com*'), // matches URLs containing 'example.com'
action: async (ctx) => {
// ctx.commander has all methods, but they throw ActionStoppedError if navigation happens
// ctx.checkStopped() - call in loops to check if should stop
// ctx.abortSignal - use with fetch() for cancellation
// ctx.onCleanup(fn) - register cleanup when action stops
console.log(`Processing: ${ctx.url}`);
// Safe iteration - stops if navigation detected
await ctx.forEach(['item1', 'item2'], async (item) => {
await ctx.commander.clickButton({ selector: `[data-id="${item}"]` });
});
},
});
// 4. Navigate - action auto-starts when page is ready
await commander.goto({ url: 'https://example.com' });
// 5. Cleanup
await commander.destroy();
await browser.close();The makeUrlCondition helper makes it easy to create URL matching conditions:
import { makeUrlCondition, allConditions, anyCondition, notCondition } from 'browser-commander';
// Exact URL match
makeUrlCondition('https://example.com/page')
// Contains substring (use * wildcards)
makeUrlCondition('*checkout*') // URL contains 'checkout'
makeUrlCondition('*example.com*') // URL contains 'example.com'
// Starts with / ends with
makeUrlCondition('/api/*') // starts with '/api/'
makeUrlCondition('*.json') // ends with '.json'
// Express-style route patterns
makeUrlCondition('/vacancy/:id') // matches /vacancy/123
makeUrlCondition('https://hh.ru/vacancy/:vacancyId') // matches specific domain + path
makeUrlCondition('/user/:userId/profile') // multiple segments
// RegExp
makeUrlCondition(/\/product\/\d+/)
// Custom function (receives full context)
makeUrlCondition((url, ctx) => {
const parsed = new URL(url);
return parsed.pathname.startsWith('/admin') && parsed.searchParams.has('edit');
})
// Combine conditions
allConditions(
makeUrlCondition('*example.com*'),
makeUrlCondition('*/checkout*')
) // Both must match
anyCondition(
makeUrlCondition('*/cart*'),
makeUrlCondition('*/checkout*')
) // Either matches
notCondition(makeUrlCondition('*/admin*')) // NegationWhen navigation is detected:
- Action is signaled to stop (AbortController.abort())
- Wait for action to finish (up to 10 seconds for graceful cleanup)
- Only then start waiting for page load
This ensures:
- No DOM operations on stale/loading pages
- Actions can do proper cleanup (clear intervals, save state)
- No race conditions between action and navigation
When your action is called, it receives a context object with these properties:
commander.pageTrigger({
name: 'my-trigger',
condition: makeUrlCondition('*/checkout*'),
action: async (ctx) => {
// Current URL
ctx.url; // 'https://example.com/checkout'
// Trigger name (for debugging)
ctx.triggerName; // 'my-trigger'
// Check if action should stop
ctx.isStopped(); // Returns true if navigation detected
// Throw ActionStoppedError if stopped (use in manual loops)
ctx.checkStopped();
// AbortSignal - use with fetch() or other cancellable APIs
ctx.abortSignal;
// Safe wait (throws if stopped during wait)
await ctx.wait(1000);
// Safe iteration (checks stopped between items)
await ctx.forEach(items, async (item) => {
await ctx.commander.clickButton({ selector: item.selector });
});
// Register cleanup (runs when action stops)
ctx.onCleanup(() => {
console.log('Cleaning up...');
});
// Commander with all methods wrapped to throw on stop
await ctx.commander.fillTextArea({ selector: 'input', text: 'hello' });
// Raw commander (use carefully - does not auto-throw)
ctx.rawCommander;
},
});const commander = makeBrowserCommander({
page, // Required: Playwright/Puppeteer page
verbose: false, // Enable debug logging
enableNetworkTracking: true, // Track HTTP requests
enableNavigationManager: true, // Enable navigation events
});const unregister = commander.pageTrigger({
name: 'trigger-name', // For debugging
condition: (ctx) => boolean, // When to run (receives {url, commander})
action: async (ctx) => void, // What to do
priority: 0, // Higher runs first
});await commander.goto({
url: 'https://example.com',
waitUntil: 'domcontentloaded', // Playwright/Puppeteer option
timeout: 60000,
});await commander.clickButton({
selector: 'button.submit',
scrollIntoView: true,
waitForNavigation: true,
});await commander.fillTextArea({
selector: 'textarea.message',
text: 'Hello world',
checkEmpty: true,
});await commander.destroy(); // Stop actions, cleanup// BAD: Won't stop on navigation
for (const item of items) {
await ctx.commander.click({ selector: item });
}
// GOOD: Stops immediately on navigation
await ctx.forEach(items, async (item) => {
await ctx.commander.click({ selector: item });
});action: async (ctx) => {
while (hasMorePages) {
ctx.checkStopped(); // Throws if navigation detected
await processPage(ctx);
hasMorePages = await ctx.commander.isVisible({ selector: '.next' });
}
}action: async (ctx) => {
const intervalId = setInterval(updateStatus, 1000);
ctx.onCleanup(() => {
clearInterval(intervalId);
console.log('Interval cleared');
});
// ... rest of action
}action: async (ctx) => {
const response = await fetch(url, {
signal: ctx.abortSignal, // Cancels on navigation
});
}Enable verbose mode for detailed logs:
const commander = makeBrowserCommander({ page, verbose: true });See src/ARCHITECTURE.md for detailed architecture documentation.