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:
2026-04-08 21:28:01 +03:00
parent afc4627353
commit 5cc16725fb
88 changed files with 12637 additions and 405 deletions
+166
View File
@@ -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();
}
}
}