mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
refactor(swipe): split swipe screen modules
Move swipe screen logic into hooks/services and extract state-specific UI components to keep files small while preserving existing behavior. Reference: US-002
This commit is contained in:
1 parent
209dc652d2
commit
8d4d9d8750
55 files changed
+11127
-1681
No files matched your search
@@ -0,0 +1,287 @@
|
||||
import express, { type Express, type Request, type Response } from "express";
|
||||
import { chromium, type BrowserContext, type Page } from "playwright";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import type { Socket } from "net";
|
||||
import type {
|
||||
ServeOptions,
|
||||
GetPageRequest,
|
||||
GetPageResponse,
|
||||
ListPagesResponse,
|
||||
ServerInfoResponse,
|
||||
} from "./types";
|
||||
|
||||
export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse };
|
||||
|
||||
export interface DevBrowserServer {
|
||||
wsEndpoint: string;
|
||||
port: number;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Helper to retry fetch with exponential backoff
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
maxRetries = 5,
|
||||
delayMs = 500
|
||||
): Promise<globalThis.Response> {
|
||||
let lastError: Error | null = null;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return res;
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed after ${maxRetries} retries: ${lastError?.message}`);
|
||||
}
|
||||
|
||||
// Helper to add timeout to promises
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function serve(options: ServeOptions = {}): Promise<DevBrowserServer> {
|
||||
const port = options.port ?? 9222;
|
||||
const headless = options.headless ?? false;
|
||||
const cdpPort = options.cdpPort ?? 9223;
|
||||
const profileDir = options.profileDir;
|
||||
|
||||
// Validate port numbers
|
||||
if (port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`);
|
||||
}
|
||||
if (cdpPort < 1 || cdpPort > 65535) {
|
||||
throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`);
|
||||
}
|
||||
if (port === cdpPort) {
|
||||
throw new Error("port and cdpPort must be different");
|
||||
}
|
||||
|
||||
// Determine user data directory for persistent context
|
||||
const userDataDir = profileDir
|
||||
? join(profileDir, "browser-data")
|
||||
: join(process.cwd(), ".browser-data");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
console.log(`Using persistent browser profile: ${userDataDir}`);
|
||||
|
||||
console.log("Launching browser with persistent context...");
|
||||
|
||||
// Launch persistent context - this persists cookies, localStorage, cache, etc.
|
||||
const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless,
|
||||
args: [`--remote-debugging-port=${cdpPort}`],
|
||||
});
|
||||
console.log("Browser launched with persistent profile...");
|
||||
|
||||
// Get the CDP WebSocket endpoint from Chrome's JSON API (with retry for slow startup)
|
||||
const cdpResponse = await fetchWithRetry(`http://127.0.0.1:${cdpPort}/json/version`);
|
||||
const cdpInfo = (await cdpResponse.json()) as { webSocketDebuggerUrl: string };
|
||||
const wsEndpoint = cdpInfo.webSocketDebuggerUrl;
|
||||
console.log(`CDP WebSocket endpoint: ${wsEndpoint}`);
|
||||
|
||||
// Registry entry type for page tracking
|
||||
interface PageEntry {
|
||||
page: Page;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
// Registry: name -> PageEntry
|
||||
const registry = new Map<string, PageEntry>();
|
||||
|
||||
// Helper to get CDP targetId for a page
|
||||
async function getTargetId(page: Page): Promise<string> {
|
||||
const cdpSession = await context.newCDPSession(page);
|
||||
try {
|
||||
const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
|
||||
return targetInfo.targetId;
|
||||
} finally {
|
||||
await cdpSession.detach();
|
||||
}
|
||||
}
|
||||
|
||||
// Express server for page management
|
||||
const app: Express = express();
|
||||
app.use(express.json());
|
||||
|
||||
// GET / - server info
|
||||
app.get("/", (_req: Request, res: Response) => {
|
||||
const response: ServerInfoResponse = { wsEndpoint };
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// GET /pages - list all pages
|
||||
app.get("/pages", (_req: Request, res: Response) => {
|
||||
const response: ListPagesResponse = {
|
||||
pages: Array.from(registry.keys()),
|
||||
};
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// POST /pages - get or create page
|
||||
app.post("/pages", async (req: Request, res: Response) => {
|
||||
const body = req.body as GetPageRequest;
|
||||
const { name, viewport } = body;
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
res.status(400).json({ error: "name is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.length === 0) {
|
||||
res.status(400).json({ error: "name cannot be empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.length > 256) {
|
||||
res.status(400).json({ error: "name must be 256 characters or less" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if page already exists
|
||||
let entry = registry.get(name);
|
||||
if (!entry) {
|
||||
// Create new page in the persistent context (with timeout to prevent hangs)
|
||||
const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s");
|
||||
|
||||
// Apply viewport if provided
|
||||
if (viewport) {
|
||||
await page.setViewportSize(viewport);
|
||||
}
|
||||
|
||||
const targetId = await getTargetId(page);
|
||||
entry = { page, targetId };
|
||||
registry.set(name, entry);
|
||||
|
||||
// Clean up registry when page is closed (e.g., user clicks X)
|
||||
page.on("close", () => {
|
||||
registry.delete(name);
|
||||
});
|
||||
}
|
||||
|
||||
const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId };
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// DELETE /pages/:name - close a page
|
||||
app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => {
|
||||
const name = decodeURIComponent(req.params.name);
|
||||
const entry = registry.get(name);
|
||||
|
||||
if (entry) {
|
||||
await entry.page.close();
|
||||
registry.delete(name);
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(404).json({ error: "page not found" });
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`HTTP API server running on port ${port}`);
|
||||
});
|
||||
|
||||
// Track active connections for clean shutdown
|
||||
const connections = new Set<Socket>();
|
||||
server.on("connection", (socket: Socket) => {
|
||||
connections.add(socket);
|
||||
socket.on("close", () => connections.delete(socket));
|
||||
});
|
||||
|
||||
// Track if cleanup has been called to avoid double cleanup
|
||||
let cleaningUp = false;
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = async () => {
|
||||
if (cleaningUp) return;
|
||||
cleaningUp = true;
|
||||
|
||||
console.log("\nShutting down...");
|
||||
|
||||
// Close all active HTTP connections
|
||||
for (const socket of connections) {
|
||||
socket.destroy();
|
||||
}
|
||||
connections.clear();
|
||||
|
||||
// Close all pages
|
||||
for (const entry of registry.values()) {
|
||||
try {
|
||||
await entry.page.close();
|
||||
} catch {
|
||||
// Page might already be closed
|
||||
}
|
||||
}
|
||||
registry.clear();
|
||||
|
||||
// Close context (this also closes the browser)
|
||||
try {
|
||||
await context.close();
|
||||
} catch {
|
||||
// Context might already be closed
|
||||
}
|
||||
|
||||
server.close();
|
||||
console.log("Server stopped.");
|
||||
};
|
||||
|
||||
// Synchronous cleanup for forced exits
|
||||
const syncCleanup = () => {
|
||||
try {
|
||||
context.close();
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
};
|
||||
|
||||
// Signal handlers (consolidated to reduce duplication)
|
||||
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
||||
|
||||
const signalHandler = async () => {
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
const errorHandler = async (err: unknown) => {
|
||||
console.error("Unhandled error:", err);
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
// Register handlers
|
||||
signals.forEach((sig) => process.on(sig, signalHandler));
|
||||
process.on("uncaughtException", errorHandler);
|
||||
process.on("unhandledRejection", errorHandler);
|
||||
process.on("exit", syncCleanup);
|
||||
|
||||
// Helper to remove all handlers
|
||||
const removeHandlers = () => {
|
||||
signals.forEach((sig) => process.off(sig, signalHandler));
|
||||
process.off("uncaughtException", errorHandler);
|
||||
process.off("unhandledRejection", errorHandler);
|
||||
process.off("exit", syncCleanup);
|
||||
};
|
||||
|
||||
return {
|
||||
wsEndpoint,
|
||||
port,
|
||||
async stop() {
|
||||
removeHandlers();
|
||||
await cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user