chore(repo): restructure as monorepo with server and client workspaces
- move NestJS app into server/ subdirectory - add client/ React+TypeScript (Vite) app with Hello World - update docker-compose to build and run both services - add root package.json declaring npm workspaces - update .gitignore to cover node_modules and dist at all depths
This commit is contained in:
@@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
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,
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all stored sessions (paginated)" })
|
||||
@ApiResponse({ status: 200, description: "Paginated sessions" })
|
||||
findAll(@Query() query: PaginationQueryDto<SessionOrderBy>) {
|
||||
return this.sessionService.findAll(query);
|
||||
}
|
||||
|
||||
@Delete(":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<void> {
|
||||
await this.sessionContextService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
export type SessionStatus = "open" | "closed";
|
||||
|
||||
@Entity("sessions")
|
||||
export class SessionEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
sessionName: string;
|
||||
|
||||
@Column("text")
|
||||
token: string;
|
||||
|
||||
@Column("text")
|
||||
cookies: string; // JSON-serialised Cookie[] from Playwright
|
||||
|
||||
@Column("text", { default: "{}" })
|
||||
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()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
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, SessionContextService, SessionSchedulerService],
|
||||
exports: [SessionService, SessionContextService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Injectable, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/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"
|
||||
| "status"
|
||||
| "lastUsedAt"
|
||||
| "createdAt"
|
||||
| "updatedAt";
|
||||
|
||||
@Injectable()
|
||||
export class SessionService implements OnApplicationBootstrap {
|
||||
private readonly logger = new TraceLogger(SessionService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(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(
|
||||
sessionName: string,
|
||||
token: string,
|
||||
cookies: Cookie[],
|
||||
localStorage: Record<string, string>,
|
||||
): Promise<SessionEntity> {
|
||||
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);
|
||||
}
|
||||
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
sessionName,
|
||||
token,
|
||||
cookies: JSON.stringify(cookies),
|
||||
localStorage: JSON.stringify(localStorage),
|
||||
status: "open",
|
||||
lastUsedAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
findBySessionName(sessionName: string): Promise<SessionEntity | null> {
|
||||
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(
|
||||
query: PaginationQueryDto<SessionOrderBy> = {},
|
||||
): Promise<
|
||||
PaginatedResult<
|
||||
Pick<
|
||||
SessionEntity,
|
||||
| "id"
|
||||
| "sessionName"
|
||||
| "status"
|
||||
| "lastUsedAt"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
>
|
||||
>
|
||||
> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const orderBy = query.orderBy ?? "id";
|
||||
const orderDir = query.orderDir ?? "ASC";
|
||||
const [data, total] = await this.repo.findAndCount({
|
||||
select: [
|
||||
"id",
|
||||
"sessionName",
|
||||
"status",
|
||||
"lastUsedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
],
|
||||
order: { [orderBy]: orderDir },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user