From 9898fb6472cbd7fe5af372fbb0e3f61fa85c7fc9 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 7 Apr 2026 12:36:27 +0300 Subject: [PATCH] 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 --- src/browser/browser.controller.ts | 23 ++++++++++- src/browser/browser.service.ts | 64 ++++++++++++++++++++++++++++++- src/browser/dto/exec.dto.ts | 26 +++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 src/browser/dto/exec.dto.ts diff --git a/src/browser/browser.controller.ts b/src/browser/browser.controller.ts index 7ee4618..95f59cc 100644 --- a/src/browser/browser.controller.ts +++ b/src/browser/browser.controller.ts @@ -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 { 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 { + return this.browserService.exec(dto.sessionName, dto.code, dto.url); + } } diff --git a/src/browser/browser.service.ts b/src/browser/browser.service.ts index 2e35d77..4f7be3b 100644 --- a/src/browser/browser.service.ts +++ b/src/browser/browser.service.ts @@ -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 { 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 { + const session = await this.sessionService.findBySessionName(sessionName); + if (!session) { + throw new NotFoundException(`Session not found: ${sessionName}`); + } + + let cookies: any[]; + let localStorageData: Record; + 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) => { + 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(); + } + } } diff --git a/src/browser/dto/exec.dto.ts b/src/browser/dto/exec.dto.ts new file mode 100644 index 0000000..ac1393f --- /dev/null +++ b/src/browser/dto/exec.dto.ts @@ -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; +}