feat(browser): add POST /exec for custom Playwright code execution

- accepts sessionName, optional url, and arbitrary JS code string
- restores cookies and localStorage from session before execution
- exposes page and context to user code as async function arguments
- session not found now returns 404 instead of 400
This commit is contained in:
2026-04-07 12:36:27 +03:00
parent 9e5a813e9d
commit 9898fb6472
3 changed files with 110 additions and 3 deletions
+21 -2
View File
@@ -1,7 +1,8 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { BrowserService, OpenResult } from './browser.service';
import { BrowserService, ExecResult, OpenResult } from './browser.service';
import { OpenDto } from './dto/open.dto';
import { ExecDto } from './dto/exec.dto';
@ApiTags('browser')
@Controller()
@@ -21,9 +22,27 @@ export class BrowserController {
},
},
})
@ApiResponse({ status: 400, description: 'Session not found or invalid input' })
@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);
}
@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> {
return this.browserService.exec(dto.sessionName, dto.code, dto.url);
}
}
+63 -1
View File
@@ -3,6 +3,7 @@ import {
BadRequestException,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import { chromium } from 'playwright';
import { Readability } from '@mozilla/readability';
@@ -15,6 +16,10 @@ export interface OpenResult {
content: string;
}
export interface ExecResult {
result: unknown;
}
@Injectable()
export class BrowserService {
private readonly logger = new Logger(BrowserService.name);
@@ -24,7 +29,7 @@ export class BrowserService {
async open(sessionName: string, url: string, readerMode = false): Promise<OpenResult> {
const session = await this.sessionService.findBySessionName(sessionName);
if (!session) {
throw new BadRequestException(`Session not found: ${sessionName}`);
throw new NotFoundException(`Session not found: ${sessionName}`);
}
let cookies: any[];
@@ -74,6 +79,7 @@ export class BrowserService {
} catch (err) {
if (
err instanceof BadRequestException ||
err instanceof NotFoundException ||
err instanceof InternalServerErrorException
) {
throw err;
@@ -86,4 +92,60 @@ export class BrowserService {
await browser.close();
}
}
async exec(sessionName: string, code: string, url?: string): Promise<ExecResult> {
const session = await this.sessionService.findBySessionName(sessionName);
if (!session) {
throw new NotFoundException(`Session not found: ${sessionName}`);
}
let cookies: any[];
let localStorageData: Record<string, string>;
try {
cookies = JSON.parse(session.cookies);
localStorageData = JSON.parse(session.localStorage);
} catch {
throw new InternalServerErrorException('Failed to deserialize session data');
}
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext();
await context.addCookies(cookies);
const page = await context.newPage();
await context.addInitScript((entries: Record<string, string>) => {
for (const [k, v] of Object.entries(entries)) {
window.localStorage.setItem(k, v);
}
}, localStorageData);
if (url) {
this.logger.log(`[${sessionName}] exec: navigating to ${url}`);
await page.goto(url, { waitUntil: 'networkidle' });
}
// eslint-disable-next-line no-new-func
const fn = new Function('page', 'context', `return (async (page, context) => { ${code} })(page, context)`);
this.logger.log(`[${sessionName}] exec: running user code`);
const result = await fn(page, context);
this.logger.log(`[${sessionName}] exec: done`);
return { result };
} catch (err) {
if (
err instanceof BadRequestException ||
err instanceof NotFoundException ||
err instanceof InternalServerErrorException
) {
throw err;
}
this.logger.error(`[${sessionName}] exec failed: ${(err as Error).message}`);
throw new InternalServerErrorException(
`Browser exec failed: ${(err as Error).message}`,
);
} finally {
await browser.close();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUrl } from 'class-validator';
export class ExecDto {
@ApiProperty({
description: 'Session name previously created by POST /login',
example: 'test-session-1',
})
@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;
}