Skip to content

Commit 55cd555

Browse files
committed
manage browser process directly, auto-detect lightpanda or chrome
Replace chromedp.NewExecAllocator with our own os/exec launch so exec mode works for both chrome and lightpanda. resolveBrowser searches PATH lightpanda-first, then chrome variants; explicit exec <path> wins and the kind is inferred from the basename. Lightpanda is started as 'lightpanda serve --host 127.0.0.1 --port <picked>'; chrome gets the default flag set + a temp --user-data-dir + --remote-debugging-port. waitForBrowser polls /json/version until ready. Cleanup sends SIGINT (5 s grace, then SIGKILL) and removes the temp dir.
1 parent e8f6275 commit 55cd555

4 files changed

Lines changed: 201 additions & 64 deletions

File tree

LIGHTPANDA.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,16 @@ same code path is used for Chrome: it's also faster there because each render
4343
gets its own root browser session instead of paying for
4444
`Target.createBrowserContext` / disposal on a shared WS reader.
4545

46-
For `exec` mode the middleware launches the browser once with
47-
`--remote-debugging-port=<picked>`, keeps a long-lived bootstrap WS open so
48-
chromedp's watchdog doesn't kill the process, and serves every render through
49-
its own fresh WS to the same browser.
46+
For `exec` mode the middleware itself launches the browser via `os/exec`
47+
(not chromedp's allocator), so both chrome and lightpanda are launchable.
48+
PATH is searched lightpanda-first, falling back to chrome variants;
49+
explicit `exec <path>` wins. For lightpanda the command is
50+
`lightpanda serve --host 127.0.0.1 --port <picked>` plus user flags; for
51+
chrome it's the default flag set + `--user-data-dir=<temp>` +
52+
`--remote-debugging-port=<picked>`. The middleware polls `/json/version`
53+
until ready, then serves every render through its own fresh WS to the
54+
same browser. On `Cleanup` the process gets SIGINT (5 s grace, then SIGKILL)
55+
and the temp user-data-dir is removed.
5056

5157
`Provision` for `url` mode uses a one-shot WS to log the browser version, then
5258
discards it.
@@ -180,11 +186,10 @@ workflow does this; the README documents it.
180186
[middleware.go](middleware.go) `detectLightpanda`, setting `m.lightpanda`
181187
when `/json/version` says `"Browser":"Lightpanda/..."`.
182188
- **Provision**[middleware.go](middleware.go): for `exec` mode picks a
183-
free port, launches the browser via `chromedp.NewExecAllocator` with
184-
`--remote-debugging-port=<picked>`, and keeps a single bootstrap WS open
185-
for the lifetime of the middleware so chromedp's watchdog doesn't kill the
186-
process. For `url` mode it stores the configured URL and runs a one-shot
187-
remote probe to log the browser version.
189+
free port, resolves a binary via `resolveBrowser` (lightpanda first, then
190+
chrome), launches it with `exec.Command`, polls `/json/version` until
191+
ready, then runs a one-shot remote probe to log the browser version. For
192+
`url` mode it stores the configured URL and runs the same one-shot probe.
188193
- **Per-request flow**[middleware_serve_http.go](middleware_serve_http.go):
189194
- bypass-header short-circuit at the top of `ServeHTTP`,
190195
- one fresh `chromedp.NewRemoteAllocator` + `chromedp.NewContext` per

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,17 @@ chrome {
5555
- `timeout` - maximum time to wait for Chrome to render the page, default is `10s`.
5656
- `mime_types` - list of MIME types to render, default is `text/html`.
5757
- Browser (only one of these):
58-
- `exec` - executes the local browser binary by given path, if the first argument starts with a dash (`-`), the binary is automatically found in the path and all the arguments are treated as additional flags on top of the [default flags](https://pkg.go.dev/github.com/chromedp/chromedp#pkg-variables)
59-
- `exec_no_default_flags` - the same as `exec` but without the default flags
58+
- `exec` - the middleware launches a browser process itself and connects to it. If a path is given, that binary is used; otherwise PATH is searched: **lightpanda first, then chrome variants** (`google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser`, plus `/Applications/Google Chrome.app/...` and `/Applications/Chromium.app/...` on macOS). For chrome-kind binaries a sensible default flag set is applied; for lightpanda the middleware runs it as `lightpanda serve --host 127.0.0.1 --port <picked>`. Extra flags after `--` are appended to the launch command. The browser kind is inferred from the binary basename (anything containing "lightpanda" → lightpanda mode).
59+
- `exec_no_default_flags` - the same as `exec` but without the default chrome flags (no effect for lightpanda)
6060
- `url` - URL to the debugging protocol endpoint of a remote browser instance
6161
- `fullfill_hosts` - a list of hosts to issue as internal requests through the webserver, there's automatically the host of the original request
6262
- `continue_hosts` - a list of hosts to let Chrome do the regular network requests
6363

64-
Every render opens a fresh CDP WebSocket to the browser. For `exec` mode the middleware launches the browser once on a known port and connects to it per request; for `url` mode it opens a new WS per request to the configured remote endpoint. This avoids the per-request `Target.createBrowserContext` round-trips on a shared connection (faster on Chrome) and gives Lightpanda 0.2.4+ a separate browser per connection (true concurrency).
64+
Every render opens a fresh CDP WebSocket to the browser. For `exec` mode the middleware launches the browser once on a known port (managed via `os/exec`, not chromedp's allocator, so both chrome and lightpanda are supported) and connects to it per request; for `url` mode it opens a new WS per request to the configured remote endpoint. This avoids the per-request `Target.createBrowserContext` round-trips on a shared connection (faster on Chrome) and gives Lightpanda 0.2.4+ a separate browser per connection (true concurrency).
6565

6666
## Browsers
6767

68-
In addition to headless Chrome, [Lightpanda](https://lightpanda.io/) is supported as a CDP-compatible backend. Start it as `lightpanda serve --host 127.0.0.1 --port 9222` and point caddy-chrome at it via `url http://127.0.0.1:9222/`. Lightpanda is detected automatically (via `/json/version`) and the middleware switches to a single-target rendering mode:
68+
In addition to headless Chrome, [Lightpanda](https://lightpanda.io/) is supported as a CDP-compatible backend. The simplest setup is `exec` with no path — if `lightpanda` is in PATH it'll be picked over chrome and the middleware launches `lightpanda serve` itself. Alternatively, run it externally as `lightpanda serve --host 127.0.0.1 --port 9222` and point caddy-chrome at it via `url http://127.0.0.1:9222/`. Lightpanda is detected automatically (via `/json/version`) and the middleware switches to a single-target rendering mode:
6969

7070
- as for Chrome, every render opens a fresh CDP WebSocket — Lightpanda 0.2.4+ gives each connection its own full browser, so concurrent requests render in parallel;
7171
- `Fetch` interception is skipped — Lightpanda's CDP processes commands serially per session, and dispatching `Fetch.fulfillRequest`/`continueRequest` for a sub-resource while the navigation fulfillment is being parsed deadlocks the WS read loop (see [lightpanda-io/browser#2391](https://github.com/lightpanda-io/browser/issues/2391)). Instead, caddy-chrome sets a `X-Caddy-Chrome-Bypass` header via `Network.setExtraHTTPHeaders` and lets Lightpanda fetch the navigation and every sub-resource (XHR, `fetch()`, `<script>`) directly from the same Caddy server. The middleware sees the marker header and passes the request straight to the next handler;

middleware.go

Lines changed: 176 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ import (
1212
"github.com/chromedp/cdproto/cdp"
1313
"github.com/chromedp/chromedp"
1414
"go.uber.org/zap"
15+
"io"
1516
"net"
1617
"net/http"
18+
"os"
19+
"os/exec"
20+
"path/filepath"
1721
"strconv"
1822
"strings"
1923
"time"
@@ -33,18 +37,15 @@ type Middleware struct {
3337
FulfillHosts []string `json:"fulfill_hosts,omitempty"`
3438
ContinueHosts []string `json:"continue_hosts,omitempty"`
3539
Links bool `json:"links,omitempty"`
36-
log *zap.Logger
37-
timeout time.Duration
38-
// allocCtx owns the underlying browser process (for exec) or the
39-
// long-lived chromedp.RemoteAllocator wrapper. Per-request work uses a
40-
// fresh chromedp.NewRemoteAllocator(browserURL) so every render gets its
41-
// own CDP WebSocket — this is what gives concurrent requests true
42-
// isolation.
43-
allocCtx context.Context
44-
allocCancel context.CancelFunc
45-
keepAliveCtx context.Context
46-
keepCancel context.CancelFunc
47-
browserURL string
40+
log *zap.Logger
41+
timeout time.Duration
42+
// In exec mode the middleware itself launches and owns the browser
43+
// process (cmd) and its temp user-data-dir (tempDir). Per-request work
44+
// always uses a fresh chromedp.NewRemoteAllocator(browserURL) so every
45+
// render gets its own CDP WebSocket.
46+
cmd *exec.Cmd
47+
tempDir string
48+
browserURL string
4849
lightpanda bool
4950
}
5051

@@ -95,40 +96,63 @@ func (m *Middleware) Provision(ctx caddy.Context) (err error) {
9596
}()
9697

9798
if m.ExecBrowser != nil {
98-
// Pick a free port for chrome to listen on so we can build a stable
99-
// HTTP debugging URL and reuse it for per-request remote allocators.
10099
port, perr := pickFreePort()
101100
if perr != nil {
102101
return fmt.Errorf("pick debug port: %w", perr)
103102
}
104-
var opts []chromedp.ExecAllocatorOption
105-
if m.ExecBrowser.Path != "" {
106-
opts = append(opts, chromedp.ExecPath(m.ExecBrowser.Path))
103+
104+
execPath, kind, perr := resolveBrowser(m.ExecBrowser.Path)
105+
if perr != nil {
106+
return perr
107107
}
108-
if m.ExecBrowser.DefaultFlags {
109-
opts = append(opts, chromedp.DefaultExecAllocatorOptions[:]...)
108+
m.lightpanda = kind == browserLightpanda
109+
110+
var args []string
111+
switch kind {
112+
case browserLightpanda:
113+
args = append(args, "serve", "--host", "127.0.0.1", "--port", strconv.Itoa(port))
114+
args = append(args, m.ExecBrowser.Flags...)
115+
case browserChrome:
116+
if m.ExecBrowser.DefaultFlags {
117+
args = append(args, defaultChromeFlags...)
118+
}
119+
args = append(args, m.ExecBrowser.Flags...)
120+
m.tempDir, perr = os.MkdirTemp("", "caddy-chrome-")
121+
if perr != nil {
122+
return fmt.Errorf("create user-data-dir: %w", perr)
123+
}
124+
args = append(args, "--user-data-dir="+m.tempDir)
125+
args = append(args, "--remote-debugging-port="+strconv.Itoa(port))
110126
}
111-
for _, flag := range m.ExecBrowser.Flags {
112-
parts := strings.SplitN(flag, "=", 2)
113-
opts = append(opts, chromedp.Flag(parts[0], parts[1]))
127+
128+
m.log.Info("starting browser",
129+
zap.String("path", execPath),
130+
zap.Bool("lightpanda", m.lightpanda),
131+
zap.Strings("args", args))
132+
133+
m.cmd = exec.Command(execPath, args...)
134+
m.cmd.Stdout = io.Discard
135+
m.cmd.Stderr = io.Discard
136+
if err = m.cmd.Start(); err != nil {
137+
return fmt.Errorf("start %s: %w", execPath, err)
114138
}
115-
// Override the default --remote-debugging-port=0 so we know the URL up
116-
// front. chromedp only sets that flag if it's not already provided.
117-
opts = append(opts, chromedp.Flag("remote-debugging-port", strconv.Itoa(port)))
118-
m.allocCtx, m.allocCancel = chromedp.NewExecAllocator(context.Background(), opts...)
119-
// Bootstrap a long-lived CDP connection so chrome stays running. The
120-
// chromedp ExecAllocator kills the chrome process when its single
121-
// browser connection drops, so we keep this one open for the lifetime
122-
// of the middleware.
123-
m.keepAliveCtx, m.keepCancel = chromedp.NewContext(m.allocCtx)
124-
if err = chromedp.Run(m.keepAliveCtx, chromedp.ActionFunc(func(ctx context.Context) error {
139+
m.browserURL = fmt.Sprintf("http://127.0.0.1:%d/", port)
140+
141+
if err = waitForBrowser(m.browserURL, 10*time.Second); err != nil {
142+
return fmt.Errorf("wait for browser: %w", err)
143+
}
144+
145+
probeAlloc, probeAllocCancel := chromedp.NewRemoteAllocator(context.Background(), m.browserURL)
146+
defer probeAllocCancel()
147+
probeCtx, probeCancel := chromedp.NewContext(probeAlloc)
148+
defer probeCancel()
149+
if err = chromedp.Run(probeCtx, chromedp.ActionFunc(func(ctx context.Context) error {
125150
c := chromedp.FromContext(ctx)
126151
bctx := cdp.WithExecutor(ctx, c.Browser)
127-
return logBrowserVersion(bctx, m.log, false)
152+
return logBrowserVersion(bctx, m.log, m.lightpanda)
128153
})); err != nil {
129-
return fmt.Errorf("start chrome: %w", err)
154+
return fmt.Errorf("connect to browser: %w", err)
130155
}
131-
m.browserURL = fmt.Sprintf("http://127.0.0.1:%d/", port)
132156

133157
} else {
134158
m.browserURL = m.RemoteBrowser.URL
@@ -166,6 +190,109 @@ func logBrowserVersion(ctx context.Context, log *zap.Logger, lightpanda bool) er
166190
return nil
167191
}
168192

193+
type browserKind int
194+
195+
const (
196+
browserChrome browserKind = iota
197+
browserLightpanda
198+
)
199+
200+
var lightpandaBinaries = []string{"lightpanda"}
201+
202+
var chromeBinaries = []string{
203+
"google-chrome",
204+
"google-chrome-stable",
205+
"chromium",
206+
"chromium-browser",
207+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
208+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
209+
}
210+
211+
var defaultChromeFlags = []string{
212+
"--headless",
213+
"--disable-gpu",
214+
"--no-first-run",
215+
"--no-default-browser-check",
216+
"--disable-background-networking",
217+
"--enable-features=NetworkService,NetworkServiceInProcess",
218+
"--disable-background-timer-throttling",
219+
"--disable-backgrounding-occluded-windows",
220+
"--disable-breakpad",
221+
"--disable-client-side-phishing-detection",
222+
"--disable-default-apps",
223+
"--disable-dev-shm-usage",
224+
"--disable-extensions",
225+
"--disable-hang-monitor",
226+
"--disable-ipc-flooding-protection",
227+
"--disable-popup-blocking",
228+
"--disable-prompt-on-repost",
229+
"--disable-renderer-backgrounding",
230+
"--disable-sync",
231+
"--force-color-profile=srgb",
232+
"--metrics-recording-only",
233+
"--mute-audio",
234+
"--safebrowsing-disable-auto-update",
235+
"--enable-automation",
236+
"--password-store=basic",
237+
"--use-mock-keychain",
238+
}
239+
240+
// resolveBrowser picks which browser binary to launch. If execPath is set the
241+
// caller's choice wins and the kind is inferred from the basename. Otherwise
242+
// PATH is searched: lightpanda first, then chrome variants.
243+
func resolveBrowser(execPath string) (string, browserKind, error) {
244+
if execPath != "" {
245+
base := strings.ToLower(filepath.Base(execPath))
246+
if strings.Contains(base, "lightpanda") {
247+
return execPath, browserLightpanda, nil
248+
}
249+
return execPath, browserChrome, nil
250+
}
251+
for _, name := range lightpandaBinaries {
252+
if p, err := exec.LookPath(name); err == nil {
253+
return p, browserLightpanda, nil
254+
}
255+
}
256+
for _, name := range chromeBinaries {
257+
if filepath.IsAbs(name) {
258+
if _, err := os.Stat(name); err == nil {
259+
return name, browserChrome, nil
260+
}
261+
continue
262+
}
263+
if p, err := exec.LookPath(name); err == nil {
264+
return p, browserChrome, nil
265+
}
266+
}
267+
return "", 0, fmt.Errorf("no browser found in PATH (tried lightpanda, chrome variants); set exec <path>")
268+
}
269+
270+
// waitForBrowser polls /json/version on the launched browser until it answers
271+
// or the deadline expires.
272+
func waitForBrowser(remoteURL string, timeout time.Duration) error {
273+
versionURL := strings.TrimSuffix(remoteURL, "/") + "/json/version"
274+
deadline := time.Now().Add(timeout)
275+
client := &http.Client{Timeout: 1 * time.Second}
276+
var lastErr error
277+
for time.Now().Before(deadline) {
278+
resp, err := client.Get(versionURL)
279+
if err == nil {
280+
resp.Body.Close()
281+
if resp.StatusCode == http.StatusOK {
282+
return nil
283+
}
284+
lastErr = fmt.Errorf("status %d", resp.StatusCode)
285+
} else {
286+
lastErr = err
287+
}
288+
time.Sleep(50 * time.Millisecond)
289+
}
290+
if lastErr != nil {
291+
return fmt.Errorf("timeout waiting for %s: %w", versionURL, lastErr)
292+
}
293+
return fmt.Errorf("timeout waiting for %s", versionURL)
294+
}
295+
169296
func pickFreePort() (int, error) {
170297
l, err := net.Listen("tcp", "127.0.0.1:0")
171298
if err != nil {
@@ -202,23 +329,22 @@ func detectLightpanda(remoteURL string) bool {
202329
}
203330

204331
func (m *Middleware) cleanup() {
205-
if m.keepCancel != nil {
206-
// Cancelling the keep-alive context ends the chromedp managed browser
207-
// connection, which lets ExecAllocator's watchdog tear down chrome.
208-
m.keepCancel()
209-
m.keepCancel = nil
210-
}
211-
if m.allocCtx != nil {
212-
timeoutCtx, c := context.WithTimeout(m.allocCtx, 10*time.Second)
213-
defer c()
214-
_ = chromedp.Cancel(timeoutCtx)
332+
if m.cmd != nil && m.cmd.Process != nil {
333+
_ = m.cmd.Process.Signal(os.Interrupt)
334+
done := make(chan error, 1)
335+
go func() { done <- m.cmd.Wait() }()
336+
select {
337+
case <-done:
338+
case <-time.After(5 * time.Second):
339+
_ = m.cmd.Process.Kill()
340+
<-done
341+
}
342+
m.cmd = nil
215343
}
216-
if m.allocCancel != nil {
217-
m.allocCancel()
218-
m.allocCancel = nil
344+
if m.tempDir != "" {
345+
_ = os.RemoveAll(m.tempDir)
346+
m.tempDir = ""
219347
}
220-
m.allocCtx = nil
221-
m.keepAliveCtx = nil
222348
}
223349

224350
func (m *Middleware) Cleanup() error {

middleware_serve_http_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@ func TestMiddleware_ServeHTTP(t *testing.T) {
1919
return http.ErrUseLastResponse
2020
}
2121
browserConfig := "exec"
22+
isLightpanda := false
2223
if u := os.Getenv("CADDY_CHROME_TEST_BROWSER_URL"); u != "" {
2324
browserConfig = "url " + u
25+
isLightpanda = true
26+
} else if _, kind, err := resolveBrowser(""); err == nil && kind == browserLightpanda {
27+
// exec mode picked lightpanda from PATH; pass the flag the test
28+
// server's self-signed cert needs.
29+
browserConfig = "exec -- --insecure-disable-tls-host-verification"
30+
isLightpanda = true
2431
}
2532
tester.InitServer(fmt.Sprintf(`
2633
{
@@ -55,7 +62,6 @@ func TestMiddleware_ServeHTTP(t *testing.T) {
5562
file_server
5663
}`, browserConfig), "caddyfile")
5764

58-
isLightpanda := os.Getenv("CADDY_CHROME_TEST_BROWSER_URL") != ""
5965
for _, testCase := range []struct {
6066
url string
6167
statusCode int

0 commit comments

Comments
 (0)