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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user