Skip to content

Commit 4f6fd8e

Browse files
authored
support serverless environments (#29)
* add --executable-path * tests * test vercel * fixes
1 parent 3cd0ab4 commit 4f6fd8e

File tree

13 files changed

+255
-12
lines changed

13 files changed

+255
-12
lines changed

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,34 @@ jobs:
155155
exit 1
156156
}
157157
shell: pwsh
158+
159+
serverless-chromium:
160+
name: Serverless Chromium (@sparticuz/chromium)
161+
runs-on: ubuntu-latest
162+
163+
steps:
164+
- name: Checkout repository
165+
uses: actions/checkout@v4
166+
167+
- name: Setup pnpm
168+
uses: pnpm/action-setup@v4
169+
with:
170+
version: 9
171+
172+
- name: Setup Node.js
173+
uses: actions/setup-node@v4
174+
with:
175+
node-version: 22
176+
cache: pnpm
177+
178+
- name: Install dependencies
179+
run: pnpm install
180+
181+
- name: Install @sparticuz/chromium
182+
run: pnpm add -D @sparticuz/chromium
183+
184+
- name: Build TypeScript
185+
run: pnpm build
186+
187+
- name: Run serverless integration test
188+
run: pnpm exec vitest run test/serverless.test.ts

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ agent-browser snapshot -i -c -d 5 # Combine options
293293
| Option | Description |
294294
|--------|-------------|
295295
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
296+
| `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) |
296297
| `--json` | JSON output (for agents) |
297298
| `--full, -f` | Full page screenshot |
298299
| `--name, -n` | Locator name filter |
@@ -387,6 +388,39 @@ agent-browser open example.com --headed
387388
388389
This opens a visible browser window instead of running headless.
389390
391+
## Custom Browser Executable
392+
393+
Use a custom browser executable instead of the bundled Chromium. This is useful for:
394+
- **Serverless deployment**: Use lightweight Chromium builds like `@sparticuz/chromium` (~50MB vs ~684MB)
395+
- **System browsers**: Use an existing Chrome/Chromium installation
396+
- **Custom builds**: Use modified browser builds
397+
398+
### CLI Usage
399+
400+
```bash
401+
# Via flag
402+
agent-browser --executable-path /path/to/chromium open example.com
403+
404+
# Via environment variable
405+
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
406+
```
407+
408+
### Serverless Example (Vercel/AWS Lambda)
409+
410+
```typescript
411+
import chromium from '@sparticuz/chromium';
412+
import { BrowserManager } from 'agent-browser';
413+
414+
export async function handler() {
415+
const browser = new BrowserManager();
416+
await browser.launch({
417+
executablePath: await chromium.executablePath(),
418+
headless: true,
419+
});
420+
// ... use browser
421+
}
422+
```
423+
390424
## Architecture
391425
392426
agent-browser uses a client-daemon architecture:

cli/src/commands.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,7 @@ mod tests {
886886
full: false,
887887
headed: false,
888888
debug: false,
889+
executable_path: None,
889890
}
890891
}
891892

cli/src/connection.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,15 @@ fn daemon_ready(session: &str) -> bool {
153153
}
154154
}
155155

156-
pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
156+
/// Result of ensure_daemon indicating whether a new daemon was started
157+
pub struct DaemonResult {
158+
/// True if we connected to an existing daemon, false if we started a new one
159+
pub already_running: bool,
160+
}
161+
162+
pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>) -> Result<DaemonResult, String> {
157163
if is_daemon_running(session) && daemon_ready(session) {
158-
return Ok(());
164+
return Ok(DaemonResult { already_running: true });
159165
}
160166

161167
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
@@ -186,6 +192,10 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
186192
cmd.env("AGENT_BROWSER_HEADED", "1");
187193
}
188194

195+
if let Some(path) = executable_path {
196+
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
197+
}
198+
189199
// Create new process group and session to fully detach
190200
unsafe {
191201
cmd.pre_exec(|| {
@@ -220,6 +230,10 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
220230
cmd.env("AGENT_BROWSER_HEADED", "1");
221231
}
222232

233+
if let Some(path) = executable_path {
234+
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
235+
}
236+
223237
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
224238
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
225239
const DETACHED_PROCESS: u32 = 0x00000008;
@@ -234,7 +248,7 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
234248

235249
for _ in 0..50 {
236250
if daemon_ready(session) {
237-
return Ok(());
251+
return Ok(DaemonResult { already_running: false });
238252
}
239253
thread::sleep(Duration::from_millis(100));
240254
}

cli/src/flags.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub struct Flags {
66
pub headed: bool,
77
pub debug: bool,
88
pub session: String,
9+
pub executable_path: Option<String>,
910
}
1011

1112
pub fn parse_flags(args: &[String]) -> Flags {
@@ -15,6 +16,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
1516
headed: false,
1617
debug: false,
1718
session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
19+
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
1820
};
1921

2022
let mut i = 0;
@@ -30,6 +32,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
3032
i += 1;
3133
}
3234
}
35+
"--executable-path" => {
36+
if let Some(s) = args.get(i + 1) {
37+
flags.executable_path = Some(s.clone());
38+
i += 1;
39+
}
40+
}
3341
_ => {}
3442
}
3543
i += 1;
@@ -43,13 +51,15 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
4351

4452
// Global flags that should be stripped from command args
4553
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
54+
// Flags that take a value (skip both the flag and the next arg)
55+
const VALUE_FLAGS: &[&str] = &["--session", "--executable-path"];
4656

4757
for arg in args.iter() {
4858
if skip_next {
4959
skip_next = false;
5060
continue;
5161
}
52-
if arg == "--session" {
62+
if VALUE_FLAGS.contains(&arg.as_str()) {
5363
skip_next = true;
5464
continue;
5565
}
@@ -61,3 +71,43 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
6171
}
6272
result
6373
}
74+
75+
#[cfg(test)]
76+
mod tests {
77+
use super::*;
78+
79+
fn args(s: &str) -> Vec<String> {
80+
s.split_whitespace().map(String::from).collect()
81+
}
82+
83+
#[test]
84+
fn test_parse_executable_path_flag() {
85+
let flags = parse_flags(&args("--executable-path /path/to/chromium open example.com"));
86+
assert_eq!(flags.executable_path, Some("/path/to/chromium".to_string()));
87+
}
88+
89+
#[test]
90+
fn test_parse_executable_path_flag_no_value() {
91+
let flags = parse_flags(&args("--executable-path"));
92+
assert_eq!(flags.executable_path, None);
93+
}
94+
95+
#[test]
96+
fn test_clean_args_removes_executable_path() {
97+
let cleaned = clean_args(&args("--executable-path /path/to/chromium open example.com"));
98+
assert_eq!(cleaned, vec!["open", "example.com"]);
99+
}
100+
101+
#[test]
102+
fn test_clean_args_removes_executable_path_with_other_flags() {
103+
let cleaned = clean_args(&args("--json --executable-path /path/to/chromium --headed open example.com"));
104+
assert_eq!(cleaned, vec!["open", "example.com"]);
105+
}
106+
107+
#[test]
108+
fn test_parse_flags_with_session_and_executable_path() {
109+
let flags = parse_flags(&args("--session test --executable-path /custom/chrome open example.com"));
110+
assert_eq!(flags.session, "test");
111+
assert_eq!(flags.executable_path, Some("/custom/chrome".to_string()));
112+
}
113+
}

cli/src/main.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,23 @@ fn main() {
149149
}
150150
};
151151

152-
if let Err(e) = ensure_daemon(&flags.session, flags.headed) {
153-
if flags.json {
154-
println!(r#"{{"success":false,"error":"{}"}}"#, e);
155-
} else {
156-
eprintln!("\x1b[31m✗\x1b[0m {}", e);
152+
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref()) {
153+
Ok(result) => result,
154+
Err(e) => {
155+
if flags.json {
156+
println!(r#"{{"success":false,"error":"{}"}}"#, e);
157+
} else {
158+
eprintln!("\x1b[31m✗\x1b[0m {}", e);
159+
}
160+
exit(1);
161+
}
162+
};
163+
164+
// Warn if executable_path was specified but daemon was already running
165+
if daemon_result.already_running && flags.executable_path.is_some() {
166+
if !flags.json {
167+
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
157168
}
158-
exit(1);
159169
}
160170

161171
// If --headed flag is set, send launch command first to switch to headed mode

cli/src/output.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,7 @@ Snapshot Options:
11861186
11871187
Options:
11881188
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
1189+
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
11891190
--json JSON output
11901191
--full, -f Full page screenshot
11911192
--headed Show browser window (not headless)

src/browser.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ describe('BrowserManager', () => {
2222
const page = browser.getPage();
2323
expect(page).toBeDefined();
2424
});
25+
26+
it('should reject invalid executablePath', async () => {
27+
const testBrowser = new BrowserManager();
28+
await expect(
29+
testBrowser.launch({
30+
headless: true,
31+
executablePath: '/nonexistent/path/to/chromium',
32+
})
33+
).rejects.toThrow();
34+
});
2535
});
2636

2737
describe('navigation', () => {

src/browser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ export class BrowserManager {
520520
// Launch browser
521521
this.browser = await launcher.launch({
522522
headless: options.headless ?? true,
523+
executablePath: options.executablePath,
523524
});
524525

525526
// Create context with viewport

src/daemon.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,12 @@ export async function startDaemon(): Promise<void> {
158158
parseResult.command.action !== 'launch' &&
159159
parseResult.command.action !== 'close'
160160
) {
161-
await browser.launch({ id: 'auto', action: 'launch', headless: true });
161+
await browser.launch({
162+
id: 'auto',
163+
action: 'launch',
164+
headless: true,
165+
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
166+
});
162167
}
163168

164169
// Handle close command specially

0 commit comments

Comments
 (0)