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