chore(repo): restructure as monorepo with server and client workspaces
- move NestJS app into server/ subdirectory - add client/ React+TypeScript (Vite) app with Hello World - update docker-compose to build and run both services - add root package.json declaring npm workspaces - update .gitignore to cover node_modules and dist at all depths
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Injectable,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { chromium } from "playwright";
|
||||
import type { BrowserContext } from "playwright";
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { SessionContextService } from "../session/session-context.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import type { ExecResult } from "../code-executor/code-executor.service";
|
||||
|
||||
export type { ExecResult } from "../code-executor/code-executor.service";
|
||||
|
||||
export interface OpenResult {
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BrowserService {
|
||||
private readonly logger = new TraceLogger(BrowserService.name);
|
||||
|
||||
constructor(
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
private readonly codeExecutor: CodeExecutorService,
|
||||
) {}
|
||||
|
||||
private rethrow(err: unknown, label: string, operation: string): never {
|
||||
if (err instanceof HttpException) {
|
||||
throw err;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
`[${label}] Browser ${operation} failed: ${(err as Error).message}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
private async extractContent(
|
||||
context: BrowserContext,
|
||||
url: string,
|
||||
readerMode: boolean,
|
||||
selector?: string,
|
||||
): Promise<OpenResult> {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
this.logger.log(`Opening ${url}`);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
|
||||
const finalUrl = page.url();
|
||||
const title = await page.title();
|
||||
const rawHtml = await page.content();
|
||||
|
||||
let content: string;
|
||||
if (selector) {
|
||||
const dom = new JSDOM(rawHtml, { url: finalUrl });
|
||||
const el = dom.window.document.querySelector(selector);
|
||||
content = readerMode
|
||||
? (el?.textContent?.replace(/\s+/g, " ").trim() ?? "")
|
||||
: (el?.outerHTML ?? "");
|
||||
} else if (readerMode) {
|
||||
const dom = new JSDOM(rawHtml, { url: finalUrl });
|
||||
const article = new Readability(dom.window.document).parse();
|
||||
content = article
|
||||
? article.textContent.replace(/\s+/g, " ").trim()
|
||||
: rawHtml;
|
||||
} else {
|
||||
content = rawHtml;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Loaded: ${finalUrl} — "${title}"${readerMode ? " (reader mode)" : ""}${selector ? ` (selector: ${selector})` : ""}`,
|
||||
);
|
||||
return { url: finalUrl, title, content };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async open(
|
||||
sessionName: string | undefined,
|
||||
url: string,
|
||||
readerMode = false,
|
||||
selector?: string,
|
||||
): Promise<OpenResult> {
|
||||
const label = sessionName ?? "anonymous";
|
||||
this.logger.log(`[${label}] open: ${url}`);
|
||||
|
||||
if (sessionName) {
|
||||
const { context } =
|
||||
await this.sessionContextService.getHandle(sessionName);
|
||||
try {
|
||||
return await this.extractContent(context, url, readerMode, selector);
|
||||
} catch (err) {
|
||||
this.rethrow(err, label, "open");
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous — ephemeral browser
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
return await this.extractContent(context, url, readerMode, selector);
|
||||
} catch (err) {
|
||||
this.rethrow(err, label, "open");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async exec(
|
||||
sessionName: string | undefined,
|
||||
code: string,
|
||||
url?: string,
|
||||
): Promise<ExecResult> {
|
||||
const label = sessionName ?? "anonymous";
|
||||
|
||||
if (sessionName) {
|
||||
const { page, context } =
|
||||
await this.sessionContextService.getHandle(sessionName);
|
||||
this.logger.log(`[${label}] exec: using persistent context`);
|
||||
try {
|
||||
if (url) {
|
||||
this.logger.log(`[${label}] exec: navigating to ${url}`);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
}
|
||||
this.logger.log(`[${label}] exec: running user code`);
|
||||
const result = await this.codeExecutor.execute(page, context, code);
|
||||
this.logger.log(`[${label}] exec: done`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.rethrow(err, label, "exec");
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous — ephemeral browser
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
if (url) {
|
||||
this.logger.log(`[${label}] exec: navigating to ${url}`);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
}
|
||||
|
||||
this.logger.log(`[${label}] exec: running user code`);
|
||||
const result = await this.codeExecutor.execute(page, context, code);
|
||||
this.logger.log(`[${label}] exec: done`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.rethrow(err, label, "exec");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user