From 9e79cad2378595cc5d5c415084d39450fea89bf7 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Wed, 8 Apr 2026 20:14:05 +0300 Subject: [PATCH] feat(session): persistent browser context pool with lifecycle management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SessionContextService: in-memory Map of live Playwright handles, reused per sessionName across exec_code/open_url calls; closed on module destroy - SessionSchedulerService: @Interval closes idle sessions and deletes old closed ones using SESSION_IDLE_TIMEOUT_MINUTES / SESSION_DELETE_CLOSED_DAYS - SessionService: onApplicationBootstrap closes all open sessions on restart; upsert marks status=open and sets lastUsedAt; adds markOpen/markClosed/ touchLastUsed/findExpiredOpen/findOldClosed/findById helpers - SessionEntity: status (open|closed) and lastUsedAt columns added - AuthService: keeps browser alive after login, registers context in pool - BrowserService: named sessions reuse persistent context; anonymous remain ephemeral - AppConfig: all config fields declared with typed defaults; validate wired into ConfigModule so mis-configuration fails fast at startup - ConfigService used everywhere — no more untyped get() calls --- .env.example | 11 +- src/app.module.ts | 6 +- src/auth/auth.service.ts | 19 +++- src/browser/browser.service.ts | 109 ++++++++++--------- src/config/app.config.ts | 27 +++++ src/main.ts | 7 +- src/mcp/mcp.service.ts | 27 +++-- src/session/session-context.service.ts | 132 +++++++++++++++++++++++ src/session/session-scheduler.service.ts | 53 +++++++++ src/session/session.controller.ts | 15 ++- src/session/session.entity.ts | 8 ++ src/session/session.module.ts | 6 +- src/session/session.service.ts | 83 ++++++++++++-- test/app.harness.ts | 7 +- 14 files changed, 424 insertions(+), 86 deletions(-) create mode 100644 src/session/session-context.service.ts create mode 100644 src/session/session-scheduler.service.ts diff --git a/.env.example b/.env.example index d316b63..ee1b48c 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,13 @@ PORT=3000 KEYS_DIR=keys # SQLite database path -DB_PATH=data/sessions.db \ No newline at end of file +DB_PATH=data/sessions.db + +# Playwright executable (set in Docker; leave empty to use system default) +# PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium + +# Session lifecycle +# Close open sessions that have been idle for longer than this many minutes +SESSION_IDLE_TIMEOUT_MINUTES=30 +# Delete closed sessions whose updatedAt is older than this many days +SESSION_DELETE_CLOSED_DAYS=7 \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index e10b820..bcb5568 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; +import { AppConfig, validateAppConfig } from "./config/app.config"; import { TypeOrmModule } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; import { HealthModule } from "./health/health.module"; @@ -21,13 +22,14 @@ import { ScenarioModule } from "./scenario/scenario.module"; ConfigModule.forRoot({ isGlobal: true, envFilePath: ".env", + validate: validateAppConfig, }), ScheduleModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], - useFactory: (config: ConfigService) => ({ + useFactory: (config: ConfigService) => ({ type: "better-sqlite3", - database: config.get("DB_PATH", "data/sessions.db"), + database: config.get("DB_PATH"), entities: [ SessionEntity, EnvironmentEntity, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d750de8..1c96122 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -6,12 +6,14 @@ import { } from "@nestjs/common"; import { TraceLogger } from "../common/trace-logger"; import { ConfigService } from "@nestjs/config"; +import { AppConfig } from "../config/app.config"; import { chromium } from "playwright"; import type { Page } from "playwright"; import * as fs from "fs"; import * as path from "path"; import * as crypto from "crypto"; import { SessionService } from "../session/session.service"; +import { SessionContextService } from "../session/session-context.service"; import { EnvironmentService } from "../environment/environment.service"; interface KeyDescriptor { @@ -26,11 +28,12 @@ export class AuthService { private readonly keysDir: string; constructor( - private readonly config: ConfigService, + private readonly config: ConfigService, private readonly sessionService: SessionService, + private readonly sessionContextService: SessionContextService, private readonly environmentService: EnvironmentService, ) { - this.keysDir = path.resolve(this.config.get("KEYS_DIR", "keys")); + this.keysDir = path.resolve(this.config.get("KEYS_DIR")); } listKeys(): string[] { @@ -169,11 +172,21 @@ export class AuthService { localStorageData, ); + // Register the live Playwright context — browser stays open for reuse + this.sessionContextService.register( + resolvedSession, + browser, + context, + page, + ); + this.logger.log( `Login successful for key ${keyId}, session: ${resolvedSession}`, ); return { token, sessionName: resolvedSession }; } catch (err) { + // Close browser only on failure — on success it is kept alive in SessionContextService + await browser.close().catch(() => {}); if ( err instanceof BadRequestException || err instanceof InternalServerErrorException @@ -185,8 +198,6 @@ export class AuthService { `Login automation failed: ${(err as Error).message}`, { cause: err }, ); - } finally { - await browser.close(); } } diff --git a/src/browser/browser.service.ts b/src/browser/browser.service.ts index 14bacf9..5485dcd 100644 --- a/src/browser/browser.service.ts +++ b/src/browser/browser.service.ts @@ -2,14 +2,13 @@ import { Injectable, HttpException, InternalServerErrorException, - NotFoundException, } from "@nestjs/common"; import { TraceLogger } from "../common/trace-logger"; import { chromium } from "playwright"; -import type { BrowserContext, Cookie } from "playwright"; +import type { BrowserContext } from "playwright"; import { Readability } from "@mozilla/readability"; import { JSDOM } from "jsdom"; -import { SessionService } from "../session/session.service"; +import { SessionContextService } from "../session/session-context.service"; import { CodeExecutorService } from "../code-executor/code-executor.service"; import type { ExecResult } from "../code-executor/code-executor.service"; @@ -26,38 +25,10 @@ export class BrowserService { private readonly logger = new TraceLogger(BrowserService.name); constructor( - private readonly sessionService: SessionService, + private readonly sessionContextService: SessionContextService, private readonly codeExecutor: CodeExecutorService, ) {} - private async setupSession( - context: BrowserContext, - sessionName: string | undefined, - ): Promise { - if (!sessionName) return; - const session = await this.sessionService.findBySessionName(sessionName); - if (!session) { - throw new NotFoundException(`Session not found: ${sessionName}`); - } - let cookies: Cookie[]; - let localStorageData: Record; - try { - cookies = JSON.parse(session.cookies); - localStorageData = JSON.parse(session.localStorage); - } catch (err) { - throw new InternalServerErrorException( - "Failed to deserialize session data", - { cause: err }, - ); - } - await context.addCookies(cookies); - await context.addInitScript((entries: Record) => { - for (const [k, v] of Object.entries(entries)) { - window.localStorage.setItem(k, v); - } - }, localStorageData); - } - private rethrow(err: unknown, label: string, operation: string): never { if (err instanceof HttpException) { throw err; @@ -68,23 +39,15 @@ export class BrowserService { ); } - async open( - sessionName: string | undefined, + private async extractContent( + context: BrowserContext, url: string, - readerMode = false, + readerMode: boolean, selector?: string, ): Promise { - const label = sessionName ?? "anonymous"; - const browser = await chromium.launch({ - headless: true, - executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, - }); + const page = await context.newPage(); try { - const context = await browser.newContext(); - await this.setupSession(context, sessionName); - const page = await context.newPage(); - - this.logger.log(`[${label}] Opening ${url}`); + this.logger.log(`Opening ${url}`); await page.goto(url, { waitUntil: "networkidle" }); const finalUrl = page.url(); @@ -109,9 +72,41 @@ export class BrowserService { } this.logger.log( - `[${label}] Loaded: ${finalUrl} — "${title}"${readerMode ? " (reader mode)" : ""}${selector ? ` (selector: ${selector})` : ""}`, + `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 { + 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 { @@ -125,13 +120,32 @@ export class BrowserService { url?: string, ): Promise { 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(); - await this.setupSession(context, sessionName); const page = await context.newPage(); if (url) { @@ -141,7 +155,6 @@ export class BrowserService { 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) { diff --git a/src/config/app.config.ts b/src/config/app.config.ts index 785d9c1..a09dee5 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -1,4 +1,6 @@ import { IsInt, IsString, Min, Max } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { validateSync } from "class-validator"; import pkg from "../../package.json"; @@ -16,4 +18,29 @@ export class AppConfig { @IsString() APP_VERSION: string = pkg.version; + + @IsString() + KEYS_DIR: string = "keys"; + + @IsString() + DB_PATH: string = "data/sessions.db"; + + @IsInt() + @Min(1) + SESSION_IDLE_TIMEOUT_MINUTES: number = 30; + + @IsInt() + @Min(1) + SESSION_DELETE_CLOSED_DAYS: number = 7; +} + +export function validateAppConfig(config: Record): AppConfig { + const validated = plainToInstance(AppConfig, config, { + enableImplicitConversion: true, + }); + const errors = validateSync(validated, { skipMissingProperties: false }); + if (errors.length > 0) { + throw new Error(errors.toString()); + } + return validated; } diff --git a/src/main.ts b/src/main.ts index 7d45338..dacb35e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { NestFactory } from "@nestjs/core"; import { ConfigService } from "@nestjs/config"; +import { AppConfig } from "./config/app.config"; import { ValidationPipe } from "@nestjs/common"; import { TraceLogger } from "./common/trace-logger"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; @@ -19,9 +20,9 @@ async function bootstrap() { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TraceInterceptor(), new LoggingInterceptor()); - const config = app.get(ConfigService); - const port = config.get("PORT", 3000); - const nodeEnv = config.get("NODE_ENV", "development"); + const config = app.get>(ConfigService); + const port = config.get("PORT"); + const nodeEnv = config.get("NODE_ENV"); const swaggerConfig = new DocumentBuilder() .setTitle(pkgName) diff --git a/src/mcp/mcp.service.ts b/src/mcp/mcp.service.ts index 46a70a6..ad88d36 100644 --- a/src/mcp/mcp.service.ts +++ b/src/mcp/mcp.service.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import type { Request, Response } from "express"; import { AuthService } from "../auth/auth.service"; import { SessionService } from "../session/session.service"; +import { SessionContextService } from "../session/session-context.service"; import { EnvironmentService } from "../environment/environment.service"; import type { EnvironmentUrls } from "../environment/environment.entity"; import { BrowserService } from "../browser/browser.service"; @@ -18,6 +19,7 @@ export class McpService { constructor( private readonly authService: AuthService, private readonly sessionService: SessionService, + private readonly sessionContextService: SessionContextService, private readonly environmentService: EnvironmentService, private readonly browserService: BrowserService, private readonly codeExecutor: CodeExecutorService, @@ -99,7 +101,14 @@ export class McpService { .optional() .describe("Items per page (default 20)"), orderBy: z - .enum(["id", "sessionName", "createdAt", "updatedAt"]) + .enum([ + "id", + "sessionName", + "status", + "lastUsedAt", + "createdAt", + "updatedAt", + ]) .optional() .describe("Field to order by (default id)"), orderDir: z @@ -124,22 +133,20 @@ export class McpService { server.registerTool( "delete_session", { - description: "Delete a session by numeric ID", + description: "Delete a session by numeric ID (closes it first if open)", inputSchema: { id: z.number().int().describe("Session ID to delete"), }, }, async ({ id }) => { - const { data } = await this.sessionService.findAll(); - if (!data.find((s) => s.id === id)) { + try { + await this.sessionContextService.delete(id); + } catch (err) { return { isError: true, - content: [ - { type: "text" as const, text: `Session ${id} not found` }, - ], + content: [{ type: "text" as const, text: (err as Error).message }], }; } - await this.sessionService.remove(id); return { content: [{ type: "text" as const, text: `Session ${id} deleted` }], }; @@ -818,9 +825,7 @@ export class McpService { .array( z.object({ order: z.number().int().min(0).describe("Execution order"), - type: z - .enum(["login", "exec", "sign"]) - .describe("Step type"), + type: z.enum(["login", "exec", "sign"]).describe("Step type"), sessionName: z.string().describe("Session name"), execCode: z .string() diff --git a/src/session/session-context.service.ts b/src/session/session-context.service.ts new file mode 100644 index 0000000..8366a72 --- /dev/null +++ b/src/session/session-context.service.ts @@ -0,0 +1,132 @@ +import { + Injectable, + OnModuleDestroy, + BadRequestException, + NotFoundException, +} from "@nestjs/common"; +import type { Browser, BrowserContext, Page } from "playwright"; +import { TraceLogger } from "../common/trace-logger"; +import { SessionService } from "./session.service"; + +export interface SessionHandle { + browser: Browser; + context: BrowserContext; + page: Page; +} + +/** + * Keeps live Playwright browser contexts in memory, keyed by sessionName. + * A session must be explicitly registered (after login) to be usable. + * Sessions marked as closed in the DB cannot be used via getHandle(). + */ +@Injectable() +export class SessionContextService implements OnModuleDestroy { + private readonly logger = new TraceLogger(SessionContextService.name); + private readonly handles = new Map(); + + constructor(private readonly sessionService: SessionService) {} + + /** + * Store a live browser context after a successful login. + * If a handle already exists for this session it is closed first. + */ + register( + sessionName: string, + browser: Browser, + context: BrowserContext, + page: Page, + ): void { + const existing = this.handles.get(sessionName); + if (existing) { + existing.browser.close().catch((err: unknown) => { + this.logger.warn( + `Error closing stale browser for "${sessionName}": ${(err as Error).message}`, + ); + }); + } + this.handles.set(sessionName, { browser, context, page }); + this.logger.log(`Session "${sessionName}" registered in context pool`); + } + + /** + * Return the live handle for a named session and bump lastUsedAt. + * Throws 404 if session does not exist in DB. + * Throws 400 if session is closed or context is not in memory. + */ + async getHandle(sessionName: string): Promise { + const handle = this.handles.get(sessionName); + if (handle) { + await this.sessionService.touchLastUsed(sessionName); + return handle; + } + + const session = await this.sessionService.findBySessionName(sessionName); + if (!session) { + throw new NotFoundException(`Session not found: ${sessionName}`); + } + if (session.status === "closed") { + throw new BadRequestException( + `Session "${sessionName}" is closed — please login again`, + ); + } + // Session is open in DB but context is not in memory (e.g. after unexpected restart). + throw new BadRequestException( + `Session "${sessionName}" context is not available — please login again`, + ); + } + + /** + * Close the Playwright browser for a session and mark it as closed in DB. + * Safe to call even if the session is not currently in memory. + */ + async close(sessionName: string): Promise { + const handle = this.handles.get(sessionName); + if (handle) { + try { + await handle.browser.close(); + } catch (err) { + this.logger.warn( + `Error closing browser for session "${sessionName}": ${(err as Error).message}`, + ); + } + this.handles.delete(sessionName); + } + await this.sessionService.markClosed(sessionName); + this.logger.log(`Session "${sessionName}" closed`); + } + + /** + * Close all in-memory handles and mark every open session as closed in DB. + */ + async closeAll(): Promise { + const names = Array.from(this.handles.keys()); + await Promise.allSettled(names.map((name) => this.close(name))); + if (names.length > 0) { + this.logger.log(`Closed ${names.length} session context(s)`); + } + } + + /** + * Close the context (if open) and delete the session record from DB. + * Throws 404 if the session ID does not exist. + */ + async delete(id: number): Promise { + const session = await this.sessionService.findById(id); + if (!session) { + throw new NotFoundException(`Session ${id} not found`); + } + if (this.handles.has(session.sessionName)) { + await this.close(session.sessionName); + } + await this.sessionService.remove(id); + } + + /** Returns true if a live Playwright context exists for this session. */ + isOpen(sessionName: string): boolean { + return this.handles.has(sessionName); + } + + async onModuleDestroy(): Promise { + await this.closeAll(); + } +} diff --git a/src/session/session-scheduler.service.ts b/src/session/session-scheduler.service.ts new file mode 100644 index 0000000..ceca126 --- /dev/null +++ b/src/session/session-scheduler.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from "@nestjs/common"; +import { Interval } from "@nestjs/schedule"; +import { ConfigService } from "@nestjs/config"; +import { AppConfig } from "../config/app.config"; +import { TraceLogger } from "../common/trace-logger"; +import { SessionService } from "./session.service"; +import { SessionContextService } from "./session-context.service"; + +/** + * Periodically: + * 1. Closes sessions that have been idle for longer than SESSION_IDLE_TIMEOUT_MINUTES. + * 2. Deletes closed sessions whose updatedAt is older than SESSION_DELETE_CLOSED_DAYS. + */ +@Injectable() +export class SessionSchedulerService { + private readonly logger = new TraceLogger(SessionSchedulerService.name); + + constructor( + private readonly sessionService: SessionService, + private readonly sessionContextService: SessionContextService, + private readonly config: ConfigService, + ) {} + + @Interval(60_000) + async runScheduler(): Promise { + await this.closeExpired(); + await this.deleteOldClosed(); + } + + private async closeExpired(): Promise { + const idleMinutes = this.config.get("SESSION_IDLE_TIMEOUT_MINUTES"); + const cutoff = new Date(Date.now() - idleMinutes * 60_000); + const expired = await this.sessionService.findExpiredOpen(cutoff); + for (const session of expired) { + await this.sessionContextService.close(session.sessionName); + this.logger.log( + `Session "${session.sessionName}" closed (idle > ${idleMinutes} min)`, + ); + } + } + + private async deleteOldClosed(): Promise { + const days = this.config.get("SESSION_DELETE_CLOSED_DAYS"); + const cutoff = new Date(Date.now() - days * 86_400_000); + const old = await this.sessionService.findOldClosed(cutoff); + for (const session of old) { + await this.sessionService.remove(session.id); + this.logger.log( + `Session "${session.sessionName}" deleted (closed > ${days} days ago)`, + ); + } + } +} diff --git a/src/session/session.controller.ts b/src/session/session.controller.ts index abe3c0e..8e3f8f6 100644 --- a/src/session/session.controller.ts +++ b/src/session/session.controller.ts @@ -2,20 +2,23 @@ import { Controller, Delete, Get, - NotFoundException, Param, ParseIntPipe, Query, } from "@nestjs/common"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { SessionService } from "./session.service"; +import { SessionContextService } from "./session-context.service"; import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { SessionOrderBy } from "./session.service"; @ApiTags("sessions") @Controller("sessions") export class SessionController { - constructor(private readonly sessionService: SessionService) {} + constructor( + private readonly sessionService: SessionService, + private readonly sessionContextService: SessionContextService, + ) {} @Get() @ApiOperation({ summary: "List all stored sessions (paginated)" }) @@ -25,14 +28,10 @@ export class SessionController { } @Delete(":id") - @ApiOperation({ summary: "Delete a session by ID" }) + @ApiOperation({ summary: "Delete a session by ID (closes it first if open)" }) @ApiResponse({ status: 200, description: "Session deleted" }) @ApiResponse({ status: 404, description: "Session not found" }) async remove(@Param("id", ParseIntPipe) id: number): Promise { - const sessions = await this.sessionService.findAll(); - if (!sessions.data.find((s) => s.id === id)) { - throw new NotFoundException(`Session ${id} not found`); - } - await this.sessionService.remove(id); + await this.sessionContextService.delete(id); } } diff --git a/src/session/session.entity.ts b/src/session/session.entity.ts index 7f9bcfc..fb5132b 100644 --- a/src/session/session.entity.ts +++ b/src/session/session.entity.ts @@ -6,6 +6,8 @@ import { UpdateDateColumn, } from "typeorm"; +export type SessionStatus = "open" | "closed"; + @Entity("sessions") export class SessionEntity { @PrimaryGeneratedColumn() @@ -23,6 +25,12 @@ export class SessionEntity { @Column("text", { default: "{}" }) localStorage: string; // JSON-serialised Record from Playwright + @Column({ default: "closed" }) + status: SessionStatus; + + @Column({ type: "datetime", nullable: true, default: null }) + lastUsedAt: Date | null; + @CreateDateColumn() createdAt: Date; diff --git a/src/session/session.module.ts b/src/session/session.module.ts index 3c2cbda..d063fc0 100644 --- a/src/session/session.module.ts +++ b/src/session/session.module.ts @@ -3,11 +3,13 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { SessionEntity } from "./session.entity"; import { SessionService } from "./session.service"; import { SessionController } from "./session.controller"; +import { SessionContextService } from "./session-context.service"; +import { SessionSchedulerService } from "./session-scheduler.service"; @Module({ imports: [TypeOrmModule.forFeature([SessionEntity])], controllers: [SessionController], - providers: [SessionService], - exports: [SessionService], + providers: [SessionService, SessionContextService, SessionSchedulerService], + exports: [SessionService, SessionContextService], }) export class SessionModule {} diff --git a/src/session/session.service.ts b/src/session/session.service.ts index e156847..1c563d0 100644 --- a/src/session/session.service.ts +++ b/src/session/session.service.ts @@ -1,34 +1,58 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, OnApplicationBootstrap } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { LessThan, Repository } from "typeorm"; import { SessionEntity } from "./session.entity"; +import { TraceLogger } from "../common/trace-logger"; import type { Cookie } from "playwright"; import { PaginationQueryDto, PaginatedResult, } from "../common/dto/pagination.dto"; -export type SessionOrderBy = "id" | "sessionName" | "createdAt" | "updatedAt"; +export type SessionOrderBy = + | "id" + | "sessionName" + | "status" + | "lastUsedAt" + | "createdAt" + | "updatedAt"; @Injectable() -export class SessionService { +export class SessionService implements OnApplicationBootstrap { + private readonly logger = new TraceLogger(SessionService.name); + constructor( @InjectRepository(SessionEntity) private readonly repo: Repository, ) {} + async onApplicationBootstrap(): Promise { + const result = await this.repo.update( + { status: "open" }, + { status: "closed" }, + ); + if ((result.affected ?? 0) > 0) { + this.logger.log( + `${result.affected} open session(s) closed on startup (no Playwright context available)`, + ); + } + } + async upsert( sessionName: string, token: string, cookies: Cookie[], localStorage: Record, ): Promise { + const now = new Date(); const existing = await this.repo.findOneBy({ sessionName }); if (existing) { existing.token = token; existing.cookies = JSON.stringify(cookies); existing.localStorage = JSON.stringify(localStorage); + existing.status = "open"; + existing.lastUsedAt = now; return this.repo.save(existing); } @@ -38,6 +62,8 @@ export class SessionService { token, cookies: JSON.stringify(cookies), localStorage: JSON.stringify(localStorage), + status: "open", + lastUsedAt: now, }), ); } @@ -46,11 +72,49 @@ export class SessionService { return this.repo.findOneBy({ sessionName }); } + findById(id: number): Promise { + return this.repo.findOneBy({ id }); + } + + async markOpen(sessionName: string): Promise { + await this.repo.update({ sessionName }, { status: "open" }); + } + + async markClosed(sessionName: string): Promise { + await this.repo.update({ sessionName }, { status: "closed" }); + } + + async touchLastUsed(sessionName: string): Promise { + await this.repo.update({ sessionName }, { lastUsedAt: new Date() }); + } + + findExpiredOpen(cutoff: Date): Promise { + return this.repo + .createQueryBuilder("s") + .where("s.status = :status", { status: "open" }) + .andWhere("(s.lastUsedAt IS NULL OR s.lastUsedAt < :cutoff)", { cutoff }) + .getMany(); + } + + findOldClosed(cutoff: Date): Promise { + return this.repo.find({ + where: { status: "closed", updatedAt: LessThan(cutoff) }, + }); + } + async findAll( query: PaginationQueryDto = {}, ): Promise< PaginatedResult< - Pick + Pick< + SessionEntity, + | "id" + | "sessionName" + | "status" + | "lastUsedAt" + | "createdAt" + | "updatedAt" + > > > { const page = query.page ?? 1; @@ -58,7 +122,14 @@ export class SessionService { const orderBy = query.orderBy ?? "id"; const orderDir = query.orderDir ?? "ASC"; const [data, total] = await this.repo.findAndCount({ - select: ["id", "sessionName", "createdAt", "updatedAt"], + select: [ + "id", + "sessionName", + "status", + "lastUsedAt", + "createdAt", + "updatedAt", + ], order: { [orderBy]: orderDir }, skip: (page - 1) * limit, take: limit, diff --git a/test/app.harness.ts b/test/app.harness.ts index 4816451..516a1e5 100644 --- a/test/app.harness.ts +++ b/test/app.harness.ts @@ -45,11 +45,16 @@ import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity"; import { HealthController } from "../src/health/health.controller"; import { HttpExceptionFilter } from "../src/filters/http-exception.filter"; import { LoggingInterceptor } from "../src/interceptors/logging.interceptor"; +import { validateAppConfig } from "../src/config/app.config"; export async function buildTestApp(): Promise { const module: TestingModule = await Test.createTestingModule({ imports: [ - ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + validate: validateAppConfig, + }), TypeOrmModule.forRoot({ type: "better-sqlite3", database: ":memory:",