feat: add persistent browser server with auto-discovery

Add `glimpse serve` which starts geckodriver + Firefox and proxies
WebDriver requests through a Unix socket at
$XDG_RUNTIME_DIR/glimpse-<uid>.sock. All commands auto-discover the
socket and reuse the running browser session (~300ms vs ~2-3s per
command).

The proxy intercepts session create/delete to keep Firefox alive:
new session requests return the existing session ID, delete session
navigates to about:blank instead of closing Firefox.

- `glimpse serve` starts in foreground, logs to stderr
- `glimpse serve --stop` sends shutdown via socket
- `glimpse serve --status` prints JSON status
- SIGTERM/SIGINT do full cleanup (Firefox + geckodriver + socket)
- Second instance detected and rejected with exit code 1
- GLIMPSE_SOCKET_PATH env var for test isolation
- Three new smoke tests for serve lifecycle
This commit is contained in:
2026-05-02 20:32:23 -04:00
parent d02df19469
commit e3d7c28820
5 changed files with 587 additions and 3 deletions

View File

@@ -42,6 +42,7 @@ Current `glimpse` subcommands:
- `exec <url> --js=<code>` or `--script=<file>` - execute JavaScript and return the result
- `screenshot <url> --output=<file>` - save a PNG screenshot
- `search <query>` - search with a supported provider and output JSON results
- `serve` - start a persistent browser server (geckodriver + Firefox) for faster repeat commands; auto-discovered via Unix socket, `--stop` to shut down, `--status` to check
## Runtime Requirements

View File

@@ -1,13 +1,20 @@
import http from "node:http";
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
import { Builder, type WebDriver } from "selenium-webdriver";
import { Builder, WebDriver } from "selenium-webdriver";
export { type WebDriver };
import firefox from "selenium-webdriver/firefox.js";
import { isServerRunning, socketPath } from "./serve.js";
const require = createRequire(import.meta.url);
const { HttpClient, Executor } = require("selenium-webdriver/http");
export interface DriverOptions {
headless?: boolean;
existingUrl?: string;
}
// Find Geckodriver
function findGeckodriver(): string {
try {
return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim();
@@ -18,10 +25,31 @@ function findGeckodriver(): string {
}
}
// Create Driver - Connects to a glimpse serve socket if available, falls
// back to an explicit --url server, or spins up an ad-hoc geckodriver.
export async function createDriver({
headless = false,
existingUrl,
}: DriverOptions = {}): Promise<WebDriver> {
// Check For Glimpse Server Socket
if (!existingUrl) {
const sock = socketPath();
const running = await isServerRunning(sock);
if (running) {
const agent = new http.Agent(
// @ts-ignore — socketPath is supported by Node but not in the type defs
{ socketPath: sock },
);
// URL is required by HttpClient but the agent routes via socket
const client = new HttpClient("http://localhost", agent);
const executor = new Executor(client);
return WebDriver.createSession(
executor,
new firefox.Options(),
) as unknown as WebDriver;
}
}
const options = new firefox.Options();
// Configure Headless

View File

@@ -3,6 +3,7 @@
import { loadConfig, type GlimpseConfig } from "./config.js";
import { createDriver, type WebDriver } from "./driver.js";
import { searchKagi, type SearchResult } from "./providers/kagi.js";
import { startServer, stopServer, serverStatus } from "./serve.js";
import { readFileSync, writeFileSync } from "node:fs";
import TurndownService from "turndown";
@@ -92,6 +93,7 @@ Commands:
exec <url> [options] Execute JavaScript on a page and return the result
screenshot <url> [options] Save a PNG screenshot of a page
search <query> [options] Search using a supported provider and return JSON results
serve [options] Start a persistent browser server for faster repeat commands
Common Options:
--help Show this help
@@ -116,6 +118,10 @@ Reader Options:
--output=<file> Write output to a file
--no-reader Skip Reader View and use raw page extraction
Serve Options:
--stop Stop a running server
--status Show server status
Search Options:
--provider=<provider> Search provider: kagi (default: config or kagi)
--token=<token> Kagi token (default: KAGI_TOKEN or config)
@@ -128,7 +134,10 @@ Examples:
glimpse exec https://example.com --js="return document.title"
glimpse exec https://example.com --script=extract.js
glimpse screenshot https://example.com --js="document.body.style.zoom = '80%'" --output=example.png
KAGI_TOKEN=... glimpse search --provider=kagi "node.js browser automation"`;
KAGI_TOKEN=... glimpse search --provider=kagi "node.js browser automation"
glimpse serve
glimpse serve --stop
glimpse serve --status`;
}
function printHelp() {
@@ -528,6 +537,16 @@ async function readerCommand() {
});
}
async function serveCommand() {
if (args.includes("--stop")) {
return stopServer();
}
if (args.includes("--status")) {
return serverStatus();
}
return startServer({ headless });
}
async function main() {
if (!command || command === "--help") {
printHelp();
@@ -548,6 +567,8 @@ async function main() {
return readerCommand();
case "search":
return searchCommand();
case "serve":
return serveCommand();
default:
unknownCommand(command);
}

421
src/serve.ts Normal file
View File

@@ -0,0 +1,421 @@
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import { ChildProcess, spawn } from "node:child_process";
import { execFileSync } from "node:child_process";
const GECKODRIVER_STARTUP_TIMEOUT = 5000;
// Socket Path - Per-user socket in XDG_RUNTIME_DIR (tmpfs, auto-cleaned on
// logout) with tmpdir fallback. GLIMPSE_SOCKET_PATH overrides for tests.
export function socketPath(): string {
if (process.env.GLIMPSE_SOCKET_PATH) return process.env.GLIMPSE_SOCKET_PATH;
const uid =
typeof process.getuid === "function" ? String(process.getuid()) : "0";
const dir = process.env.XDG_RUNTIME_DIR ?? os.tmpdir();
return path.join(dir, `glimpse-${uid}.sock`);
}
// Try Connect - Attempts to connect to an existing socket. Resolves with
// true if something is listening, false otherwise.
export function isServerRunning(sock = socketPath()): Promise<boolean> {
return new Promise((resolve) => {
const conn = net.createConnection(sock);
conn.on("connect", () => {
conn.destroy();
resolve(true);
});
conn.on("error", () => resolve(false));
});
}
// Find Geckodriver
function findGeckodriver(): string {
try {
return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim();
} catch {
throw new Error(
"geckodriver not found on $PATH. Install it (e.g. via Nix or your package manager).",
);
}
}
// Start Geckodriver - Spawns geckodriver on a random port and waits for it
// to be ready. Returns the child process and the resolved port.
async function startGeckodriver(): Promise<{
process: ChildProcess;
port: number;
}> {
const binary = findGeckodriver();
const child = spawn(binary, ["--port", "0"], {
stdio: ["ignore", "pipe", "pipe"],
});
// Wait For Port
const port = await new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill();
reject(new Error("Timed out waiting for geckodriver to start"));
}, GECKODRIVER_STARTUP_TIMEOUT);
let output = "";
child.stdout?.on("data", (chunk: Buffer) => {
output += chunk.toString();
const match = output.match(/Listening on [^\s]*:(\d+)/);
if (match) {
clearTimeout(timeout);
resolve(Number.parseInt(match[1], 10));
}
});
child.on("exit", (code) => {
clearTimeout(timeout);
reject(new Error(`geckodriver exited with code ${code}: ${output}`));
});
});
return { process: child, port };
}
// Create Firefox Session - Creates a new WebDriver session via the
// geckodriver HTTP API directly, keeping Firefox alive.
async function createSession(
geckodriverPort: number,
headless: boolean,
): Promise<string> {
const body = JSON.stringify({
capabilities: {
alwaysMatch: {
browserName: "firefox",
"moz:firefoxOptions": {
args: headless ? ["--headless"] : [],
},
},
},
});
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port: geckodriverPort,
path: "/session",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
},
},
(res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => {
try {
const parsed = JSON.parse(data);
if (parsed.value?.sessionId) {
resolve(parsed.value.sessionId);
} else {
reject(
new Error(`Failed to create session: ${data.slice(0, 500)}`),
);
}
} catch {
reject(new Error(`Invalid response from geckodriver: ${data.slice(0, 500)}`));
}
});
},
);
req.on("error", reject);
req.end(body);
});
}
// Delete Session - Closes the Firefox session via WebDriver API.
async function deleteSession(
geckodriverPort: number,
sessionId: string,
): Promise<void> {
return new Promise((resolve) => {
const req = http.request(
{
hostname: "127.0.0.1",
port: geckodriverPort,
path: `/session/${sessionId}`,
method: "DELETE",
},
() => resolve(),
);
req.on("error", () => resolve());
req.end();
});
}
// Proxy Request - Forwards an HTTP request to geckodriver over TCP.
function proxyRequest(
clientReq: http.IncomingMessage,
clientRes: http.ServerResponse,
geckodriverPort: number,
) {
// Rewrite Host Header - The client sends Host: localhost via the socket
// but geckodriver expects the actual host:port.
const headers = { ...clientReq.headers, host: `127.0.0.1:${geckodriverPort}` };
const proxyReq = http.request(
{
hostname: "127.0.0.1",
port: geckodriverPort,
path: clientReq.url,
method: clientReq.method,
headers,
},
(proxyRes) => {
clientRes.writeHead(proxyRes.statusCode ?? 500, proxyRes.headers);
proxyRes.pipe(clientRes);
},
);
proxyReq.on("error", (err) => {
clientRes.writeHead(502);
clientRes.end(JSON.stringify({ error: err.message }));
});
clientReq.pipe(proxyReq);
}
// Intercept Session Management - The proxy intercepts session creation and
// deletion to reuse the persistent Firefox session. New session requests
// return the existing session ID. Delete session requests are turned into
// navigations to about:blank instead of actually closing Firefox.
function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
geckodriverPort: number,
sessionId: string,
) {
const method = req.method?.toUpperCase();
const urlPath = req.url ?? "";
// Intercept New Session - Return the existing session ID
if (method === "POST" && urlPath === "/session") {
// Drain the request body
req.resume();
req.on("end", () => {
const response = {
value: {
sessionId,
capabilities: {},
},
};
const body = JSON.stringify(response);
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
});
res.end(body);
});
return;
}
// Intercept Delete Session - Navigate to about:blank instead
if (
method === "DELETE" &&
urlPath === `/session/${sessionId}`
) {
const navBody = JSON.stringify({ url: "about:blank" });
const navReq = http.request(
{
hostname: "127.0.0.1",
port: geckodriverPort,
path: `/session/${sessionId}/url`,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(navBody)),
},
},
(navRes) => {
let data = "";
navRes.on("data", (chunk: Buffer) => (data += chunk.toString()));
navRes.on("end", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ value: null }));
});
},
);
navReq.on("error", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ value: null }));
});
navReq.end(navBody);
return;
}
// Proxy Everything Else
proxyRequest(req, res, geckodriverPort);
}
// Shutdown Command - Sent by `glimpse serve --stop` via a special
// non-WebDriver endpoint.
const SHUTDOWN_PATH = "/__glimpse/shutdown";
const STATUS_PATH = "/__glimpse/status";
export interface ServeOptions {
headless?: boolean;
}
// Start Server - Main entry point for `glimpse serve`.
export async function startServer({
headless = true,
}: ServeOptions = {}): Promise<void> {
const sock = socketPath();
// Check For Existing Server
const running = await isServerRunning(sock);
if (running) {
console.error(`glimpse: server already running on ${sock}`);
process.exit(1);
}
// Clean Stale Socket
if (fs.existsSync(sock)) {
fs.unlinkSync(sock);
}
// Start Geckodriver
console.error("glimpse: starting geckodriver...");
const geckodriver = await startGeckodriver();
console.error(`glimpse: geckodriver listening on port ${geckodriver.port}`);
// Create Persistent Firefox Session
console.error(
`glimpse: starting Firefox${headless ? " (headless)" : ""}...`,
);
const sessionId = await createSession(geckodriver.port, headless);
console.error(`glimpse: Firefox session ${sessionId} created`);
// Create Proxy Server
const server = http.createServer((req, res) => {
const urlPath = req.url ?? "";
// Handle Shutdown
if (urlPath === SHUTDOWN_PATH) {
req.resume();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
shutdown();
return;
}
// Handle Status
if (urlPath === STATUS_PATH) {
req.resume();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
ok: true,
sessionId,
geckodriverPort: geckodriver.port,
socket: sock,
headless,
pid: process.pid,
}),
);
return;
}
handleRequest(req, res, geckodriver.port, sessionId);
});
server.listen(sock, () => {
console.error(`glimpse: server listening on ${sock}`);
});
// Cleanup Handler
let shuttingDown = false;
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
console.error("glimpse: shutting down...");
// Close Proxy Server
server.close();
// Delete Firefox Session
try {
await deleteSession(geckodriver.port, sessionId);
} catch {
// Firefox may already be gone
}
// Kill Geckodriver
geckodriver.process.kill();
// Remove Socket
try {
fs.unlinkSync(sock);
} catch {
// Socket may already be gone
}
console.error("glimpse: stopped");
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
// Stop Server - Connects to the socket and sends a shutdown request.
export async function stopServer(): Promise<void> {
const sock = socketPath();
const running = await isServerRunning(sock);
if (!running) {
console.error("glimpse: no server running");
process.exit(1);
}
return new Promise((resolve, reject) => {
const req = http.request(
{ socketPath: sock, path: SHUTDOWN_PATH, method: "POST" },
(res) => {
res.resume();
res.on("end", () => {
console.error("glimpse: server stopped");
resolve();
});
},
);
req.on("error", reject);
req.end();
});
}
// Server Status - Connects to the socket and queries status.
export async function serverStatus(): Promise<void> {
const sock = socketPath();
const running = await isServerRunning(sock);
if (!running) {
console.error("glimpse: no server running");
process.exit(1);
}
return new Promise((resolve, reject) => {
const req = http.request(
{ socketPath: sock, path: STATUS_PATH, method: "GET" },
(res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => {
process.stdout.write(data + "\n");
resolve();
});
},
);
req.on("error", reject);
req.end();
});
}

View File

@@ -10,7 +10,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { spawnSync, spawn } from "node:child_process";
import assert from "node:assert/strict";
const cliPath = new URL("../src/index.ts", import.meta.url).pathname;
@@ -163,6 +163,119 @@ test(
},
);
// Serve Helper - Starts a glimpse serve process with an isolated socket,
// waits for the "server listening" message, and returns a handle with stop().
function startServe(extraEnv = {}) {
const sock = join(tempDir, `serve-${Date.now()}.sock`);
const env = { ...process.env, GLIMPSE_SOCKET_PATH: sock, ...extraEnv };
const child = spawn(process.execPath, ["--import", "tsx", cliPath, "serve"], {
encoding: "utf-8",
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
const ready = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`serve did not start: ${stderr}`)), 15000);
child.stderr.on("data", () => {
if (stderr.includes("server listening")) {
clearTimeout(timeout);
resolve();
}
});
child.on("exit", (code) => {
clearTimeout(timeout);
reject(new Error(`serve exited early (code ${code}): ${stderr}`));
});
});
function runWithSocket(args) {
return spawnSync(process.execPath, ["--import", "tsx", cliPath, ...args], {
encoding: "utf-8",
env,
timeout: 30000,
});
}
function stop() {
runWithSocket(["serve", "--stop"]);
return new Promise((resolve) => {
child.on("exit", () => resolve());
setTimeout(() => { child.kill(); resolve(); }, 5000);
});
}
return { ready, stop, runWithSocket, sock, child };
}
test(
"serve starts and stops cleanly",
["serve"],
async () => {
const server = startServe();
await server.ready;
// Status Should Report Running
const status = server.runWithSocket(["serve", "--status"]);
assert.equal(status.status, 0, status.stderr);
const info = parseJson(status.stdout);
assert.equal(info.ok, true);
assert.equal(typeof info.sessionId, "string");
assert.equal(info.socket, server.sock);
await server.stop();
assert.equal(existsSync(server.sock), false, "socket should be cleaned up");
},
);
test(
"serve reuses browser session across commands",
["serve"],
async () => {
const server = startServe();
await server.ready;
// First Command
const r1 = server.runWithSocket([
"reader",
dataHtml("<h1>First</h1>"),
"--no-reader",
]);
assert.equal(r1.status, 0, r1.stderr);
assert.match(r1.stdout, /# First/);
// Second Command
const r2 = server.runWithSocket([
"reader",
dataHtml("<h1>Second</h1>"),
"--no-reader",
]);
assert.equal(r2.status, 0, r2.stderr);
assert.match(r2.stdout, /# Second/);
await server.stop();
},
);
test(
"serve rejects second instance",
["serve", "errors"],
async () => {
const server = startServe();
await server.ready;
// Second Instance Should Fail
const result = server.runWithSocket(["serve"]);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /already running/);
await server.stop();
},
);
test("search validates kagi token in provider", ["search", "errors"], () => {
const env = { ...process.env };
delete env.KAGI_TOKEN;