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(); } }