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
+61
View File
@@ -0,0 +1,61 @@
import { Body, Controller, Post } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { BrowserService, ExecResult, OpenResult } from "./browser.service";
import { CodeExecutorService } from "../code-executor/code-executor.service";
import { OpenDto } from "./dto/open.dto";
import { ExecDto } from "./dto/exec.dto";
@ApiTags("browser")
@Controller()
export class BrowserController {
constructor(
private readonly browserService: BrowserService,
private readonly codeExecutor: CodeExecutorService,
) {}
@Post("open")
@ApiOperation({
summary: "Open a URL with a stored session (cookies + localStorage)",
})
@ApiResponse({
status: 201,
description: "Page loaded successfully",
schema: {
properties: {
url: { type: "string" },
title: { type: "string" },
content: { type: "string" },
},
},
})
@ApiResponse({ status: 400, description: "Invalid input" })
@ApiResponse({ status: 404, description: "Session not found" })
@ApiResponse({ status: 500, description: "Browser automation failed" })
open(@Body() dto: OpenDto): Promise<OpenResult> {
return this.browserService.open(
dto.sessionName,
dto.url,
dto.readerMode ?? false,
dto.selector,
);
}
@Post("exec")
@ApiOperation({
summary: "Execute custom Playwright JavaScript within a stored session",
description:
"The `code` string is executed as an async function body with `page` (Playwright Page) and `context` (BrowserContext) in scope. The return value is serialised and returned as `result`.",
})
@ApiResponse({
status: 201,
description: "Code executed successfully",
schema: { properties: { result: {} } },
})
@ApiResponse({ status: 400, description: "Invalid input" })
@ApiResponse({ status: 404, description: "Session not found" })
@ApiResponse({ status: 500, description: "Execution failed" })
exec(@Body() dto: ExecDto): Promise<ExecResult> {
this.codeExecutor.validate(dto.code);
return this.browserService.exec(dto.sessionName, dto.code, dto.url);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { BrowserController } from "./browser.controller";
import { BrowserService } from "./browser.service";
import { SessionModule } from "../session/session.module";
import { CodeExecutorModule } from "../code-executor/code-executor.module";
@Module({
imports: [SessionModule, CodeExecutorModule],
controllers: [BrowserController],
providers: [BrowserService],
exports: [BrowserService],
})
export class BrowserModule {}
+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();
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString, IsUrl } from "class-validator";
export class ExecDto {
@ApiPropertyOptional({
description:
"Session name previously created by POST /login. If omitted, executes without a stored session.",
example: "test-session-1",
})
@IsOptional()
@IsString()
sessionName?: string;
@ApiPropertyOptional({
description:
"URL to navigate to before executing code. Skipped if omitted.",
example: "https://cabinet-liquio-diia-stg.kitsoft.ua/messages",
})
@IsOptional()
@IsUrl({ require_tld: true, require_protocol: true })
url?: string;
@ApiProperty({
description:
"JavaScript code to execute. Receives `page` (Playwright Page) and `context` (BrowserContext) as arguments. May be async. Return value is serialised and returned.",
example: "return await page.title();",
})
@IsString()
code: string;
}
+38
View File
@@ -0,0 +1,38 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl } from "class-validator";
export class OpenDto {
@ApiPropertyOptional({
description:
"Session name previously created by POST /login. If omitted, opens the URL without a stored session.",
example: "test-session-1",
})
@IsOptional()
@IsString()
sessionName?: string;
@ApiProperty({
description: "URL to open with the authenticated session",
example: "https://cabinet-liquio-diia-stg.kitsoft.ua/messages",
})
@IsUrl({ require_tld: true, require_protocol: true })
url: string;
@ApiPropertyOptional({
description:
"When true, return a plain-text reader-mode summary instead of raw HTML",
default: false,
})
@IsOptional()
@IsBoolean()
readerMode?: boolean;
@ApiPropertyOptional({
description:
"CSS selector whose matching element content is returned. When omitted the full page HTML is used.",
example: "#main-content",
})
@IsOptional()
@IsString()
selector?: string;
}