feat(session): persistent browser context pool with lifecycle management

- 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<AppConfig, true> used everywhere — no more untyped get() calls
This commit is contained in:
2026-04-08 20:14:05 +03:00
parent b2edac062f
commit 9e79cad237
14 changed files with 424 additions and 86 deletions
+9
View File
@@ -6,3 +6,12 @@ KEYS_DIR=keys
# SQLite database path # SQLite database path
DB_PATH=data/sessions.db 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
+4 -2
View File
@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config"; import { ConfigModule, ConfigService } from "@nestjs/config";
import { AppConfig, validateAppConfig } from "./config/app.config";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule"; import { ScheduleModule } from "@nestjs/schedule";
import { HealthModule } from "./health/health.module"; import { HealthModule } from "./health/health.module";
@@ -21,13 +22,14 @@ import { ScenarioModule } from "./scenario/scenario.module";
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
envFilePath: ".env", envFilePath: ".env",
validate: validateAppConfig,
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService<AppConfig, true>) => ({
type: "better-sqlite3", type: "better-sqlite3",
database: config.get<string>("DB_PATH", "data/sessions.db"), database: config.get("DB_PATH"),
entities: [ entities: [
SessionEntity, SessionEntity,
EnvironmentEntity, EnvironmentEntity,
+15 -4
View File
@@ -6,12 +6,14 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { TraceLogger } from "../common/trace-logger"; import { TraceLogger } from "../common/trace-logger";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { AppConfig } from "../config/app.config";
import { chromium } from "playwright"; import { chromium } from "playwright";
import type { Page } from "playwright"; import type { Page } from "playwright";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { SessionService } from "../session/session.service"; import { SessionService } from "../session/session.service";
import { SessionContextService } from "../session/session-context.service";
import { EnvironmentService } from "../environment/environment.service"; import { EnvironmentService } from "../environment/environment.service";
interface KeyDescriptor { interface KeyDescriptor {
@@ -26,11 +28,12 @@ export class AuthService {
private readonly keysDir: string; private readonly keysDir: string;
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService<AppConfig, true>,
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly sessionContextService: SessionContextService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
) { ) {
this.keysDir = path.resolve(this.config.get<string>("KEYS_DIR", "keys")); this.keysDir = path.resolve(this.config.get("KEYS_DIR"));
} }
listKeys(): string[] { listKeys(): string[] {
@@ -169,11 +172,21 @@ export class AuthService {
localStorageData, localStorageData,
); );
// Register the live Playwright context — browser stays open for reuse
this.sessionContextService.register(
resolvedSession,
browser,
context,
page,
);
this.logger.log( this.logger.log(
`Login successful for key ${keyId}, session: ${resolvedSession}`, `Login successful for key ${keyId}, session: ${resolvedSession}`,
); );
return { token, sessionName: resolvedSession }; return { token, sessionName: resolvedSession };
} catch (err) { } catch (err) {
// Close browser only on failure — on success it is kept alive in SessionContextService
await browser.close().catch(() => {});
if ( if (
err instanceof BadRequestException || err instanceof BadRequestException ||
err instanceof InternalServerErrorException err instanceof InternalServerErrorException
@@ -185,8 +198,6 @@ export class AuthService {
`Login automation failed: ${(err as Error).message}`, `Login automation failed: ${(err as Error).message}`,
{ cause: err }, { cause: err },
); );
} finally {
await browser.close();
} }
} }
+61 -48
View File
@@ -2,14 +2,13 @@ import {
Injectable, Injectable,
HttpException, HttpException,
InternalServerErrorException, InternalServerErrorException,
NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { TraceLogger } from "../common/trace-logger"; import { TraceLogger } from "../common/trace-logger";
import { chromium } from "playwright"; import { chromium } from "playwright";
import type { BrowserContext, Cookie } from "playwright"; import type { BrowserContext } from "playwright";
import { Readability } from "@mozilla/readability"; import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom"; 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 { CodeExecutorService } from "../code-executor/code-executor.service";
import type { ExecResult } 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); private readonly logger = new TraceLogger(BrowserService.name);
constructor( constructor(
private readonly sessionService: SessionService, private readonly sessionContextService: SessionContextService,
private readonly codeExecutor: CodeExecutorService, private readonly codeExecutor: CodeExecutorService,
) {} ) {}
private async setupSession(
context: BrowserContext,
sessionName: string | undefined,
): Promise<void> {
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<string, string>;
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<string, string>) => {
for (const [k, v] of Object.entries(entries)) {
window.localStorage.setItem(k, v);
}
}, localStorageData);
}
private rethrow(err: unknown, label: string, operation: string): never { private rethrow(err: unknown, label: string, operation: string): never {
if (err instanceof HttpException) { if (err instanceof HttpException) {
throw err; throw err;
@@ -68,23 +39,15 @@ export class BrowserService {
); );
} }
async open( private async extractContent(
sessionName: string | undefined, context: BrowserContext,
url: string, url: string,
readerMode = false, readerMode: boolean,
selector?: string, selector?: string,
): Promise<OpenResult> { ): Promise<OpenResult> {
const label = sessionName ?? "anonymous"; const page = await context.newPage();
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
});
try { try {
const context = await browser.newContext(); this.logger.log(`Opening ${url}`);
await this.setupSession(context, sessionName);
const page = await context.newPage();
this.logger.log(`[${label}] Opening ${url}`);
await page.goto(url, { waitUntil: "networkidle" }); await page.goto(url, { waitUntil: "networkidle" });
const finalUrl = page.url(); const finalUrl = page.url();
@@ -109,9 +72,41 @@ export class BrowserService {
} }
this.logger.log( 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 }; return { url: finalUrl, title, content };
} finally {
await page.close();
}
}
async open(
sessionName: string | undefined,
url: string,
readerMode = false,
selector?: string,
): Promise<OpenResult> {
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) { } catch (err) {
this.rethrow(err, label, "open"); this.rethrow(err, label, "open");
} finally { } finally {
@@ -125,13 +120,32 @@ export class BrowserService {
url?: string, url?: string,
): Promise<ExecResult> { ): Promise<ExecResult> {
const label = sessionName ?? "anonymous"; 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({ const browser = await chromium.launch({
headless: true, headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
}); });
try { try {
const context = await browser.newContext(); const context = await browser.newContext();
await this.setupSession(context, sessionName);
const page = await context.newPage(); const page = await context.newPage();
if (url) { if (url) {
@@ -141,7 +155,6 @@ export class BrowserService {
this.logger.log(`[${label}] exec: running user code`); this.logger.log(`[${label}] exec: running user code`);
const result = await this.codeExecutor.execute(page, context, code); const result = await this.codeExecutor.execute(page, context, code);
this.logger.log(`[${label}] exec: done`); this.logger.log(`[${label}] exec: done`);
return result; return result;
} catch (err) { } catch (err) {
+27
View File
@@ -1,4 +1,6 @@
import { IsInt, IsString, Min, Max } from "class-validator"; import { IsInt, IsString, Min, Max } from "class-validator";
import { plainToInstance } from "class-transformer";
import { validateSync } from "class-validator";
import pkg from "../../package.json"; import pkg from "../../package.json";
@@ -16,4 +18,29 @@ export class AppConfig {
@IsString() @IsString()
APP_VERSION: string = pkg.version; 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<string, unknown>): 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;
} }
+4 -3
View File
@@ -1,5 +1,6 @@
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { AppConfig } from "./config/app.config";
import { ValidationPipe } from "@nestjs/common"; import { ValidationPipe } from "@nestjs/common";
import { TraceLogger } from "./common/trace-logger"; import { TraceLogger } from "./common/trace-logger";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
@@ -19,9 +20,9 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TraceInterceptor(), new LoggingInterceptor()); app.useGlobalInterceptors(new TraceInterceptor(), new LoggingInterceptor());
const config = app.get(ConfigService); const config = app.get<ConfigService<AppConfig, true>>(ConfigService);
const port = config.get<number>("PORT", 3000); const port = config.get("PORT");
const nodeEnv = config.get<string>("NODE_ENV", "development"); const nodeEnv = config.get("NODE_ENV");
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle(pkgName) .setTitle(pkgName)
+16 -11
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { AuthService } from "../auth/auth.service"; import { AuthService } from "../auth/auth.service";
import { SessionService } from "../session/session.service"; import { SessionService } from "../session/session.service";
import { SessionContextService } from "../session/session-context.service";
import { EnvironmentService } from "../environment/environment.service"; import { EnvironmentService } from "../environment/environment.service";
import type { EnvironmentUrls } from "../environment/environment.entity"; import type { EnvironmentUrls } from "../environment/environment.entity";
import { BrowserService } from "../browser/browser.service"; import { BrowserService } from "../browser/browser.service";
@@ -18,6 +19,7 @@ export class McpService {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly sessionContextService: SessionContextService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly browserService: BrowserService, private readonly browserService: BrowserService,
private readonly codeExecutor: CodeExecutorService, private readonly codeExecutor: CodeExecutorService,
@@ -99,7 +101,14 @@ export class McpService {
.optional() .optional()
.describe("Items per page (default 20)"), .describe("Items per page (default 20)"),
orderBy: z orderBy: z
.enum(["id", "sessionName", "createdAt", "updatedAt"]) .enum([
"id",
"sessionName",
"status",
"lastUsedAt",
"createdAt",
"updatedAt",
])
.optional() .optional()
.describe("Field to order by (default id)"), .describe("Field to order by (default id)"),
orderDir: z orderDir: z
@@ -124,22 +133,20 @@ export class McpService {
server.registerTool( server.registerTool(
"delete_session", "delete_session",
{ {
description: "Delete a session by numeric ID", description: "Delete a session by numeric ID (closes it first if open)",
inputSchema: { inputSchema: {
id: z.number().int().describe("Session ID to delete"), id: z.number().int().describe("Session ID to delete"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
const { data } = await this.sessionService.findAll(); try {
if (!data.find((s) => s.id === id)) { await this.sessionContextService.delete(id);
} catch (err) {
return { return {
isError: true, isError: true,
content: [ content: [{ type: "text" as const, text: (err as Error).message }],
{ type: "text" as const, text: `Session ${id} not found` },
],
}; };
} }
await this.sessionService.remove(id);
return { return {
content: [{ type: "text" as const, text: `Session ${id} deleted` }], content: [{ type: "text" as const, text: `Session ${id} deleted` }],
}; };
@@ -818,9 +825,7 @@ export class McpService {
.array( .array(
z.object({ z.object({
order: z.number().int().min(0).describe("Execution order"), order: z.number().int().min(0).describe("Execution order"),
type: z type: z.enum(["login", "exec", "sign"]).describe("Step type"),
.enum(["login", "exec", "sign"])
.describe("Step type"),
sessionName: z.string().describe("Session name"), sessionName: z.string().describe("Session name"),
execCode: z execCode: z
.string() .string()
+132
View File
@@ -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<string, SessionHandle>();
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<SessionHandle> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.closeAll();
}
}
+53
View File
@@ -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<AppConfig, true>,
) {}
@Interval(60_000)
async runScheduler(): Promise<void> {
await this.closeExpired();
await this.deleteOldClosed();
}
private async closeExpired(): Promise<void> {
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<void> {
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)`,
);
}
}
}
+7 -8
View File
@@ -2,20 +2,23 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
NotFoundException,
Param, Param,
ParseIntPipe, ParseIntPipe,
Query, Query,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { SessionService } from "./session.service"; import { SessionService } from "./session.service";
import { SessionContextService } from "./session-context.service";
import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { PaginationQueryDto } from "../common/dto/pagination.dto";
import { SessionOrderBy } from "./session.service"; import { SessionOrderBy } from "./session.service";
@ApiTags("sessions") @ApiTags("sessions")
@Controller("sessions") @Controller("sessions")
export class SessionController { export class SessionController {
constructor(private readonly sessionService: SessionService) {} constructor(
private readonly sessionService: SessionService,
private readonly sessionContextService: SessionContextService,
) {}
@Get() @Get()
@ApiOperation({ summary: "List all stored sessions (paginated)" }) @ApiOperation({ summary: "List all stored sessions (paginated)" })
@@ -25,14 +28,10 @@ export class SessionController {
} }
@Delete(":id") @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: 200, description: "Session deleted" })
@ApiResponse({ status: 404, description: "Session not found" }) @ApiResponse({ status: 404, description: "Session not found" })
async remove(@Param("id", ParseIntPipe) id: number): Promise<void> { async remove(@Param("id", ParseIntPipe) id: number): Promise<void> {
const sessions = await this.sessionService.findAll(); await this.sessionContextService.delete(id);
if (!sessions.data.find((s) => s.id === id)) {
throw new NotFoundException(`Session ${id} not found`);
}
await this.sessionService.remove(id);
} }
} }
+8
View File
@@ -6,6 +6,8 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from "typeorm"; } from "typeorm";
export type SessionStatus = "open" | "closed";
@Entity("sessions") @Entity("sessions")
export class SessionEntity { export class SessionEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@@ -23,6 +25,12 @@ export class SessionEntity {
@Column("text", { default: "{}" }) @Column("text", { default: "{}" })
localStorage: string; // JSON-serialised Record<string, string> from Playwright localStorage: string; // JSON-serialised Record<string, string> from Playwright
@Column({ default: "closed" })
status: SessionStatus;
@Column({ type: "datetime", nullable: true, default: null })
lastUsedAt: Date | null;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
+4 -2
View File
@@ -3,11 +3,13 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { SessionEntity } from "./session.entity"; import { SessionEntity } from "./session.entity";
import { SessionService } from "./session.service"; import { SessionService } from "./session.service";
import { SessionController } from "./session.controller"; import { SessionController } from "./session.controller";
import { SessionContextService } from "./session-context.service";
import { SessionSchedulerService } from "./session-scheduler.service";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([SessionEntity])], imports: [TypeOrmModule.forFeature([SessionEntity])],
controllers: [SessionController], controllers: [SessionController],
providers: [SessionService], providers: [SessionService, SessionContextService, SessionSchedulerService],
exports: [SessionService], exports: [SessionService, SessionContextService],
}) })
export class SessionModule {} export class SessionModule {}
+77 -6
View File
@@ -1,34 +1,58 @@
import { Injectable } from "@nestjs/common"; import { Injectable, OnApplicationBootstrap } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm"; import { LessThan, Repository } from "typeorm";
import { SessionEntity } from "./session.entity"; import { SessionEntity } from "./session.entity";
import { TraceLogger } from "../common/trace-logger";
import type { Cookie } from "playwright"; import type { Cookie } from "playwright";
import { import {
PaginationQueryDto, PaginationQueryDto,
PaginatedResult, PaginatedResult,
} from "../common/dto/pagination.dto"; } from "../common/dto/pagination.dto";
export type SessionOrderBy = "id" | "sessionName" | "createdAt" | "updatedAt"; export type SessionOrderBy =
| "id"
| "sessionName"
| "status"
| "lastUsedAt"
| "createdAt"
| "updatedAt";
@Injectable() @Injectable()
export class SessionService { export class SessionService implements OnApplicationBootstrap {
private readonly logger = new TraceLogger(SessionService.name);
constructor( constructor(
@InjectRepository(SessionEntity) @InjectRepository(SessionEntity)
private readonly repo: Repository<SessionEntity>, private readonly repo: Repository<SessionEntity>,
) {} ) {}
async onApplicationBootstrap(): Promise<void> {
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( async upsert(
sessionName: string, sessionName: string,
token: string, token: string,
cookies: Cookie[], cookies: Cookie[],
localStorage: Record<string, string>, localStorage: Record<string, string>,
): Promise<SessionEntity> { ): Promise<SessionEntity> {
const now = new Date();
const existing = await this.repo.findOneBy({ sessionName }); const existing = await this.repo.findOneBy({ sessionName });
if (existing) { if (existing) {
existing.token = token; existing.token = token;
existing.cookies = JSON.stringify(cookies); existing.cookies = JSON.stringify(cookies);
existing.localStorage = JSON.stringify(localStorage); existing.localStorage = JSON.stringify(localStorage);
existing.status = "open";
existing.lastUsedAt = now;
return this.repo.save(existing); return this.repo.save(existing);
} }
@@ -38,6 +62,8 @@ export class SessionService {
token, token,
cookies: JSON.stringify(cookies), cookies: JSON.stringify(cookies),
localStorage: JSON.stringify(localStorage), localStorage: JSON.stringify(localStorage),
status: "open",
lastUsedAt: now,
}), }),
); );
} }
@@ -46,11 +72,49 @@ export class SessionService {
return this.repo.findOneBy({ sessionName }); return this.repo.findOneBy({ sessionName });
} }
findById(id: number): Promise<SessionEntity | null> {
return this.repo.findOneBy({ id });
}
async markOpen(sessionName: string): Promise<void> {
await this.repo.update({ sessionName }, { status: "open" });
}
async markClosed(sessionName: string): Promise<void> {
await this.repo.update({ sessionName }, { status: "closed" });
}
async touchLastUsed(sessionName: string): Promise<void> {
await this.repo.update({ sessionName }, { lastUsedAt: new Date() });
}
findExpiredOpen(cutoff: Date): Promise<SessionEntity[]> {
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<SessionEntity[]> {
return this.repo.find({
where: { status: "closed", updatedAt: LessThan(cutoff) },
});
}
async findAll( async findAll(
query: PaginationQueryDto<SessionOrderBy> = {}, query: PaginationQueryDto<SessionOrderBy> = {},
): Promise< ): Promise<
PaginatedResult< PaginatedResult<
Pick<SessionEntity, "id" | "sessionName" | "createdAt" | "updatedAt"> Pick<
SessionEntity,
| "id"
| "sessionName"
| "status"
| "lastUsedAt"
| "createdAt"
| "updatedAt"
>
> >
> { > {
const page = query.page ?? 1; const page = query.page ?? 1;
@@ -58,7 +122,14 @@ export class SessionService {
const orderBy = query.orderBy ?? "id"; const orderBy = query.orderBy ?? "id";
const orderDir = query.orderDir ?? "ASC"; const orderDir = query.orderDir ?? "ASC";
const [data, total] = await this.repo.findAndCount({ const [data, total] = await this.repo.findAndCount({
select: ["id", "sessionName", "createdAt", "updatedAt"], select: [
"id",
"sessionName",
"status",
"lastUsedAt",
"createdAt",
"updatedAt",
],
order: { [orderBy]: orderDir }, order: { [orderBy]: orderDir },
skip: (page - 1) * limit, skip: (page - 1) * limit,
take: limit, take: limit,
+6 -1
View File
@@ -45,11 +45,16 @@ import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity";
import { HealthController } from "../src/health/health.controller"; import { HealthController } from "../src/health/health.controller";
import { HttpExceptionFilter } from "../src/filters/http-exception.filter"; import { HttpExceptionFilter } from "../src/filters/http-exception.filter";
import { LoggingInterceptor } from "../src/interceptors/logging.interceptor"; import { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
import { validateAppConfig } from "../src/config/app.config";
export async function buildTestApp(): Promise<INestApplication> { export async function buildTestApp(): Promise<INestApplication> {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: true,
validate: validateAppConfig,
}),
TypeOrmModule.forRoot({ TypeOrmModule.forRoot({
type: "better-sqlite3", type: "better-sqlite3",
database: ":memory:", database: ":memory:",