initial wcag-ada
This commit is contained in:
parent
042b8cb83a
commit
d52cfe7de2
112 changed files with 9069 additions and 0 deletions
38
apps/wcag-ada/worker/src/config/index.ts
Normal file
38
apps/wcag-ada/worker/src/config/index.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { config as dotenvConfig } from "dotenv";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Load environment variables
|
||||
dotenvConfig({ path: resolve(process.cwd(), ".env") });
|
||||
|
||||
export const config = {
|
||||
// Redis configuration
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || "localhost",
|
||||
port: parseInt(process.env.REDIS_PORT || "6379", 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
db: parseInt(process.env.REDIS_DB || "0", 10),
|
||||
},
|
||||
|
||||
// Worker configuration
|
||||
worker: {
|
||||
concurrency: parseInt(process.env.WORKER_CONCURRENCY || "5", 10),
|
||||
maxJobsPerWorker: parseInt(process.env.MAX_JOBS_PER_WORKER || "100", 10),
|
||||
},
|
||||
|
||||
// Service configuration
|
||||
serviceName: "wcag-ada-worker",
|
||||
|
||||
// Scanner configuration
|
||||
scanner: {
|
||||
timeout: parseInt(process.env.SCANNER_TIMEOUT || "300000", 10), // 5 minutes
|
||||
maxRetries: parseInt(process.env.SCANNER_MAX_RETRIES || "3", 10),
|
||||
},
|
||||
|
||||
// Database URL (for Prisma)
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
|
||||
// Environment
|
||||
env: process.env.NODE_ENV || "development",
|
||||
isDevelopment: process.env.NODE_ENV \!== "production",
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
};
|
||||
312
apps/wcag-ada/worker/src/handlers/accessibility-scan.handler.ts
Normal file
312
apps/wcag-ada/worker/src/handlers/accessibility-scan.handler.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import {
|
||||
BaseHandler,
|
||||
Handler,
|
||||
Operation,
|
||||
ScheduledOperation,
|
||||
type IServiceContainer,
|
||||
} from '@stock-bot/handlers';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { AccessibilityScanner } from '@wcag-ada/scanner';
|
||||
import type { AccessibilityScanOptions } from '@wcag-ada/shared';
|
||||
import { prisma } from '../services/prisma';
|
||||
|
||||
interface ScanPayload {
|
||||
jobId: string;
|
||||
websiteId: string;
|
||||
userId: string;
|
||||
options: AccessibilityScanOptions;
|
||||
}
|
||||
|
||||
interface BatchScanPayload {
|
||||
websiteIds: string[];
|
||||
userId: string;
|
||||
options?: Partial<AccessibilityScanOptions>;
|
||||
}
|
||||
|
||||
interface CleanupPayload {
|
||||
daysToKeep?: number;
|
||||
}
|
||||
|
||||
const logger = getLogger('accessibility-scan-handler');
|
||||
|
||||
@Handler('accessibility-scan')
|
||||
export class AccessibilityScanHandler extends BaseHandler {
|
||||
private scanner: AccessibilityScanner < /dev/null | null = null;
|
||||
|
||||
constructor(services: IServiceContainer) {
|
||||
super(services);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize scanner instance
|
||||
*/
|
||||
private async ensureScanner(): Promise<AccessibilityScanner> {
|
||||
if (\!this.scanner) {
|
||||
this.scanner = new AccessibilityScanner();
|
||||
await this.scanner.initialize();
|
||||
logger.info('Scanner initialized');
|
||||
}
|
||||
return this.scanner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single website
|
||||
*/
|
||||
@Operation('scan-website')
|
||||
async scanWebsite(payload: ScanPayload): Promise<{ scanResultId: string }> {
|
||||
const { jobId, websiteId, userId, options } = payload;
|
||||
|
||||
logger.info('Starting website scan', { jobId, websiteId, userId });
|
||||
|
||||
try {
|
||||
// Update job status to running
|
||||
await prisma.scanJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'RUNNING',
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Get scanner instance
|
||||
const scanner = await this.ensureScanner();
|
||||
|
||||
// Perform scan
|
||||
const result = await scanner.scan(options);
|
||||
|
||||
// Save scan result
|
||||
const scanResult = await prisma.scanResult.create({
|
||||
data: {
|
||||
websiteId,
|
||||
jobId,
|
||||
url: result.url,
|
||||
scanDuration: result.scanDuration,
|
||||
summary: result.summary as any,
|
||||
violations: result.violations as any,
|
||||
passes: result.passes as any,
|
||||
incomplete: result.incomplete as any,
|
||||
inapplicable: result.inapplicable as any,
|
||||
pageMetadata: result.pageMetadata as any,
|
||||
wcagCompliance: result.wcagCompliance as any,
|
||||
},
|
||||
});
|
||||
|
||||
// Update job status to completed
|
||||
await prisma.scanJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update website with latest scan info
|
||||
await prisma.website.update({
|
||||
where: { id: websiteId },
|
||||
data: {
|
||||
lastScanAt: new Date(),
|
||||
complianceScore: result.summary.score,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Website scan completed', {
|
||||
jobId,
|
||||
websiteId,
|
||||
scanResultId: scanResult.id,
|
||||
score: result.summary.score,
|
||||
});
|
||||
|
||||
return { scanResultId: scanResult.id };
|
||||
} catch (error: any) {
|
||||
logger.error('Website scan failed', {
|
||||
jobId,
|
||||
websiteId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// Update job status to failed
|
||||
await prisma.scanJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
error: error.message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan multiple websites in batch
|
||||
*/
|
||||
@Operation('scan-batch')
|
||||
async scanBatch(payload: BatchScanPayload): Promise<{ jobsCreated: number }> {
|
||||
const { websiteIds, userId, options = {} } = payload;
|
||||
|
||||
logger.info('Starting batch scan', {
|
||||
websiteCount: websiteIds.length,
|
||||
userId,
|
||||
});
|
||||
|
||||
const jobs = [];
|
||||
|
||||
for (const websiteId of websiteIds) {
|
||||
try {
|
||||
// Get website details
|
||||
const website = await prisma.website.findUnique({
|
||||
where: { id: websiteId },
|
||||
});
|
||||
|
||||
if (\!website) {
|
||||
logger.warn('Website not found', { websiteId });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create scan job
|
||||
const scanJob = await prisma.scanJob.create({
|
||||
data: {
|
||||
websiteId,
|
||||
userId,
|
||||
type: 'FULL_SCAN',
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
// Queue the scan
|
||||
const jobData: ScanPayload = {
|
||||
jobId: scanJob.id,
|
||||
websiteId,
|
||||
userId,
|
||||
options: {
|
||||
url: website.url,
|
||||
...options,
|
||||
},
|
||||
};
|
||||
|
||||
// Add to queue using the service's queue manager
|
||||
await this.services.queue.addJob('accessibility-scan', 'scan-website', jobData, {
|
||||
priority: 5,
|
||||
delay: jobs.length * 1000, // Stagger jobs by 1 second
|
||||
});
|
||||
|
||||
jobs.push(scanJob.id);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create batch scan job', {
|
||||
websiteId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Batch scan jobs created', {
|
||||
jobsCreated: jobs.length,
|
||||
totalRequested: websiteIds.length,
|
||||
});
|
||||
|
||||
return { jobsCreated: jobs.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old scan results
|
||||
*/
|
||||
@ScheduledOperation('cleanup-old-scans', '0 2 * * *', {
|
||||
priority: 10,
|
||||
immediately: false,
|
||||
description: 'Clean up scan results older than specified days (default: 90)',
|
||||
})
|
||||
async cleanupOldScans(payload: CleanupPayload = {}): Promise<{ deleted: number }> {
|
||||
const daysToKeep = payload.daysToKeep || 90;
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
||||
|
||||
logger.info('Starting cleanup of old scans', {
|
||||
daysToKeep,
|
||||
cutoffDate,
|
||||
});
|
||||
|
||||
try {
|
||||
// Delete old scan results
|
||||
const deleteResult = await prisma.scanResult.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Delete orphaned scan jobs
|
||||
const deleteJobs = await prisma.scanJob.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
status: {
|
||||
in: ['COMPLETED', 'FAILED'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Cleanup completed', {
|
||||
scanResultsDeleted: deleteResult.count,
|
||||
scanJobsDeleted: deleteJobs.count,
|
||||
});
|
||||
|
||||
return { deleted: deleteResult.count + deleteJobs.count };
|
||||
} catch (error: any) {
|
||||
logger.error('Cleanup failed', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan statistics for monitoring
|
||||
*/
|
||||
@Operation('get-scan-stats')
|
||||
async getScanStats(): Promise<any> {
|
||||
const [pending, running, completed, failed, totalScans] = await Promise.all([
|
||||
prisma.scanJob.count({ where: { status: 'PENDING' } }),
|
||||
prisma.scanJob.count({ where: { status: 'RUNNING' } }),
|
||||
prisma.scanJob.count({ where: { status: 'COMPLETED' } }),
|
||||
prisma.scanJob.count({ where: { status: 'FAILED' } }),
|
||||
prisma.scanResult.count(),
|
||||
]);
|
||||
|
||||
const last24Hours = new Date();
|
||||
last24Hours.setHours(last24Hours.getHours() - 24);
|
||||
|
||||
const recentScans = await prisma.scanResult.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: last24Hours,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
jobs: {
|
||||
pending,
|
||||
running,
|
||||
completed,
|
||||
failed,
|
||||
total: pending + running + completed + failed,
|
||||
},
|
||||
scans: {
|
||||
total: totalScans,
|
||||
last24Hours: recentScans,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources when handler is destroyed
|
||||
*/
|
||||
async destroy(): Promise<void> {
|
||||
if (this.scanner) {
|
||||
await this.scanner.close();
|
||||
this.scanner = null;
|
||||
logger.info('Scanner closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
47
apps/wcag-ada/worker/src/index.ts
Normal file
47
apps/wcag-ada/worker/src/index.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { initializeWcagConfig, getWorkerConfig } from '@wcag-ada/config';
|
||||
import { logger } from './utils/logger';
|
||||
import { ScanWorker } from './workers/scan-worker';
|
||||
import { SchedulerService } from './services/scheduler';
|
||||
import { HealthService } from './services/health';
|
||||
import { gracefulShutdown } from './utils/shutdown';
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Initialize configuration
|
||||
const config = initializeWcagConfig('worker');
|
||||
const workerConfig = getWorkerConfig();
|
||||
|
||||
logger.info('Starting WCAG-ADA Worker Service', {
|
||||
environment: config.environment,
|
||||
concurrency: workerConfig.concurrency,
|
||||
queue: workerConfig.queueName,
|
||||
});
|
||||
|
||||
// Initialize services
|
||||
const scanWorker = new ScanWorker();
|
||||
const scheduler = new SchedulerService();
|
||||
const health = new HealthService();
|
||||
|
||||
// Start services
|
||||
await scanWorker.start();
|
||||
logger.info('Scan worker started');
|
||||
|
||||
if (workerConfig.scheduler.enabled) {
|
||||
await scheduler.start();
|
||||
logger.info('Scheduler service started');
|
||||
}
|
||||
|
||||
await health.start();
|
||||
logger.info('Health service started');
|
||||
|
||||
// Setup graceful shutdown
|
||||
gracefulShutdown([scanWorker, scheduler, health]);
|
||||
|
||||
logger.info('WCAG-ADA Worker Service is running');
|
||||
} catch (error) {
|
||||
logger.error('Failed to start worker service', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
90
apps/wcag-ada/worker/src/services/health.ts
Normal file
90
apps/wcag-ada/worker/src/services/health.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { createServer, Server } from 'http';
|
||||
import { Queue } from 'bullmq';
|
||||
import { getServiceConfig, getWorkerConfig } from '@wcag-ada/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { createRedisConnection } from '../utils/redis';
|
||||
|
||||
export class HealthService {
|
||||
private server: Server | null = null;
|
||||
private config = getServiceConfig('worker');
|
||||
private workerConfig = getWorkerConfig();
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.server = createServer(async (req, res) => {
|
||||
if (req.url === '/health' && req.method === 'GET') {
|
||||
const health = await this.getHealthStatus();
|
||||
res.writeHead(health.healthy ? 200 : 503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(health));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.config.port, () => {
|
||||
logger.info(`Health server listening on port ${this.config.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async getHealthStatus() {
|
||||
const checks = {
|
||||
server: 'ok',
|
||||
database: 'unknown',
|
||||
redis: 'unknown',
|
||||
queue: 'unknown',
|
||||
};
|
||||
|
||||
// Check database
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
checks.database = 'ok';
|
||||
} catch (error) {
|
||||
checks.database = 'error';
|
||||
logger.error('Database health check failed', error);
|
||||
}
|
||||
|
||||
// Check Redis
|
||||
try {
|
||||
const redis = createRedisConnection();
|
||||
await redis.ping();
|
||||
await redis.quit();
|
||||
checks.redis = 'ok';
|
||||
} catch (error) {
|
||||
checks.redis = 'error';
|
||||
logger.error('Redis health check failed', error);
|
||||
}
|
||||
|
||||
// Check queue
|
||||
try {
|
||||
const queue = new Queue(this.workerConfig.queueName, {
|
||||
connection: createRedisConnection(),
|
||||
});
|
||||
await queue.getJobCounts();
|
||||
await queue.close();
|
||||
checks.queue = 'ok';
|
||||
} catch (error) {
|
||||
checks.queue = 'error';
|
||||
logger.error('Queue health check failed', error);
|
||||
}
|
||||
|
||||
const healthy = Object.values(checks).every(status => status === 'ok');
|
||||
|
||||
return {
|
||||
healthy,
|
||||
checks,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.server) {
|
||||
return new Promise((resolve) => {
|
||||
this.server!.close(() => {
|
||||
logger.info('Health server stopped');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
55
apps/wcag-ada/worker/src/services/prisma.ts
Normal file
55
apps/wcag-ada/worker/src/services/prisma.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
|
||||
const logger = getLogger('prisma');
|
||||
|
||||
// Create a singleton instance of PrismaClient
|
||||
export const prisma = new PrismaClient({
|
||||
log: [
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'query',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'error',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'info',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Log database events in development
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
prisma.$on('query', (e: any) => {
|
||||
logger.debug('Query', {
|
||||
query: e.query,
|
||||
params: e.params,
|
||||
duration: e.duration,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
prisma.$on('error', (e: any) => {
|
||||
logger.error('Database error', { error: e });
|
||||
});
|
||||
|
||||
prisma.$on('info', (e: any) => {
|
||||
logger.info('Database info', { message: e.message });
|
||||
});
|
||||
|
||||
prisma.$on('warn', (e: any) => {
|
||||
logger.warn('Database warning', { message: e.message });
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('beforeExit', async () => {
|
||||
await prisma.$disconnect();
|
||||
logger.info('Prisma disconnected');
|
||||
});
|
||||
139
apps/wcag-ada/worker/src/services/scheduler.ts
Normal file
139
apps/wcag-ada/worker/src/services/scheduler.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import cron from 'node-cron';
|
||||
import { Queue } from 'bullmq';
|
||||
import { getWorkerConfig } from '@wcag-ada/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { createRedisConnection } from '../utils/redis';
|
||||
import type { AccessibilityScanOptions } from '@wcag-ada/shared';
|
||||
|
||||
export class SchedulerService {
|
||||
private tasks: cron.ScheduledTask[] = [];
|
||||
private scanQueue: Queue | null = null;
|
||||
private config = getWorkerConfig();
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Create queue instance
|
||||
this.scanQueue = new Queue(this.config.queueName, {
|
||||
connection: createRedisConnection(),
|
||||
});
|
||||
|
||||
// Schedule periodic check for websites that need scanning
|
||||
const task = cron.schedule('*/1 * * * *', async () => {
|
||||
await this.checkAndScheduleScans();
|
||||
}, {
|
||||
timezone: this.config.scheduler.timezone,
|
||||
});
|
||||
|
||||
this.tasks.push(task);
|
||||
task.start();
|
||||
|
||||
logger.info('Scheduler service started');
|
||||
}
|
||||
|
||||
private async checkAndScheduleScans(): Promise<void> {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// Find websites that need scanning based on their schedule
|
||||
const websites = await prisma.website.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
scanSchedule: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const website of websites) {
|
||||
const schedule = website.scanSchedule as any;
|
||||
if (!schedule || schedule.frequency === 'manual') continue;
|
||||
|
||||
const shouldScan = this.shouldScanWebsite(website, schedule, now);
|
||||
|
||||
if (shouldScan) {
|
||||
await this.scheduleWebsiteScan(website);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking scheduled scans', error);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldScanWebsite(website: any, schedule: any, now: Date): boolean {
|
||||
if (!website.lastScanAt) return true;
|
||||
|
||||
const lastScan = new Date(website.lastScanAt);
|
||||
const timeSinceLastScan = now.getTime() - lastScan.getTime();
|
||||
|
||||
switch (schedule.frequency) {
|
||||
case 'hourly':
|
||||
return timeSinceLastScan >= 60 * 60 * 1000; // 1 hour
|
||||
case 'daily':
|
||||
return timeSinceLastScan >= 24 * 60 * 60 * 1000; // 24 hours
|
||||
case 'weekly':
|
||||
return timeSinceLastScan >= 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
case 'monthly':
|
||||
return timeSinceLastScan >= 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async scheduleWebsiteScan(website: any): Promise<void> {
|
||||
try {
|
||||
// Create scan job in database
|
||||
const job = await prisma.scanJob.create({
|
||||
data: {
|
||||
websiteId: website.id,
|
||||
userId: website.userId,
|
||||
url: website.url,
|
||||
options: {
|
||||
url: website.url,
|
||||
...(website.scanOptions as any),
|
||||
authenticate: website.authConfig as any,
|
||||
},
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
// Queue the scan
|
||||
if (this.scanQueue) {
|
||||
await this.scanQueue.add('scan', {
|
||||
jobId: job.id,
|
||||
websiteId: website.id,
|
||||
userId: website.userId,
|
||||
options: job.options as AccessibilityScanOptions,
|
||||
}, {
|
||||
jobId: job.id,
|
||||
});
|
||||
|
||||
logger.info('Scheduled scan for website', {
|
||||
websiteId: website.id,
|
||||
jobId: job.id,
|
||||
url: website.url,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error scheduling website scan', {
|
||||
websiteId: website.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping scheduler service...');
|
||||
|
||||
for (const task of this.tasks) {
|
||||
task.stop();
|
||||
}
|
||||
this.tasks = [];
|
||||
|
||||
if (this.scanQueue) {
|
||||
await this.scanQueue.close();
|
||||
this.scanQueue = null;
|
||||
}
|
||||
|
||||
logger.info('Scheduler service stopped');
|
||||
}
|
||||
}
|
||||
16
apps/wcag-ada/worker/src/utils/logger.ts
Normal file
16
apps/wcag-ada/worker/src/utils/logger.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import pino from 'pino';
|
||||
import { getWcagConfig } from '@wcag-ada/config';
|
||||
|
||||
const config = getWcagConfig();
|
||||
|
||||
export const logger = pino({
|
||||
level: config.log.level,
|
||||
transport: config.environment === 'development' ? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
ignore: 'pid,hostname',
|
||||
translateTime: 'HH:MM:ss',
|
||||
},
|
||||
} : undefined,
|
||||
});
|
||||
14
apps/wcag-ada/worker/src/utils/prisma.ts
Normal file
14
apps/wcag-ada/worker/src/utils/prisma.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { getWcagConfig } from '@wcag-ada/config';
|
||||
|
||||
const config = getWcagConfig();
|
||||
|
||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
log: config.environment === 'development' ? ['error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (config.environment !== 'production') globalForPrisma.prisma = prisma;
|
||||
14
apps/wcag-ada/worker/src/utils/redis.ts
Normal file
14
apps/wcag-ada/worker/src/utils/redis.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import Redis from 'ioredis';
|
||||
import { getWorkerConfig } from '@wcag-ada/config';
|
||||
|
||||
const workerConfig = getWorkerConfig();
|
||||
|
||||
export function createRedisConnection(): Redis {
|
||||
return new Redis({
|
||||
host: workerConfig.redis.host,
|
||||
port: workerConfig.redis.port,
|
||||
password: workerConfig.redis.password,
|
||||
db: workerConfig.redis.db,
|
||||
maxRetriesPerRequest: workerConfig.redis.maxRetriesPerRequest,
|
||||
});
|
||||
}
|
||||
37
apps/wcag-ada/worker/src/utils/shutdown.ts
Normal file
37
apps/wcag-ada/worker/src/utils/shutdown.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { logger } from './logger';
|
||||
|
||||
interface Stoppable {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export function gracefulShutdown(services: Stoppable[]): void {
|
||||
const shutdown = async (signal: string) => {
|
||||
logger.info(`Received ${signal} signal, starting graceful shutdown...`);
|
||||
|
||||
try {
|
||||
// Stop all services
|
||||
await Promise.all(services.map(service => service.stop()));
|
||||
|
||||
logger.info('Graceful shutdown completed');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error during shutdown', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle shutdown signals
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
// Handle uncaught errors
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught exception', error);
|
||||
shutdown('uncaughtException');
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled rejection', { reason, promise });
|
||||
shutdown('unhandledRejection');
|
||||
});
|
||||
}
|
||||
152
apps/wcag-ada/worker/src/workers/scan-worker.ts
Normal file
152
apps/wcag-ada/worker/src/workers/scan-worker.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Worker, Job } from 'bullmq';
|
||||
import { AccessibilityScanner } from '@wcag-ada/scanner';
|
||||
import type { AccessibilityScanOptions } from '@wcag-ada/shared';
|
||||
import { getWorkerConfig } from '@wcag-ada/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { createRedisConnection } from '../utils/redis';
|
||||
|
||||
interface ScanJobData {
|
||||
jobId: string;
|
||||
websiteId: string;
|
||||
userId: string;
|
||||
options: AccessibilityScanOptions;
|
||||
}
|
||||
|
||||
export class ScanWorker {
|
||||
private worker: Worker<ScanJobData> | null = null;
|
||||
private scanner: AccessibilityScanner | null = null;
|
||||
private config = getWorkerConfig();
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Initialize scanner
|
||||
this.scanner = new AccessibilityScanner(logger);
|
||||
await this.scanner.initialize();
|
||||
|
||||
// Create worker
|
||||
this.worker = new Worker<ScanJobData>(
|
||||
this.config.queueName,
|
||||
async (job) => this.processJob(job),
|
||||
{
|
||||
connection: createRedisConnection(),
|
||||
concurrency: this.config.concurrency,
|
||||
}
|
||||
);
|
||||
|
||||
// Setup event handlers
|
||||
this.worker.on('completed', (job) => {
|
||||
logger.info('Job completed', { jobId: job.id, data: job.data });
|
||||
});
|
||||
|
||||
this.worker.on('failed', (job, err) => {
|
||||
logger.error('Job failed', {
|
||||
jobId: job?.id,
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
});
|
||||
|
||||
this.worker.on('error', (err) => {
|
||||
logger.error('Worker error', err);
|
||||
});
|
||||
}
|
||||
|
||||
private async processJob(job: Job<ScanJobData>): Promise<void> {
|
||||
const { jobId, websiteId, userId, options } = job.data;
|
||||
|
||||
logger.info('Processing scan job', { jobId, websiteId });
|
||||
|
||||
try {
|
||||
// Update job status
|
||||
await prisma.scanJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'RUNNING',
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Perform scan
|
||||
if (!this.scanner) {
|
||||
throw new Error('Scanner not initialized');
|
||||
}
|
||||
|
||||
const result = await this.scanner.scan(options);
|
||||
|
||||
// Save result
|
||||
const scanResult = await prisma.scanResult.create({
|
||||
data: {
|
||||
websiteId,
|
||||
jobId,
|
||||
url: result.url,
|
||||
scanDuration: result.scanDuration,
|
||||
summary: result.summary as any,
|
||||
violations: result.violations as any,
|
||||
passes: result.passes as any,
|
||||
incomplete: result.incomplete as any,
|
||||
inapplicable: result.inapplicable as any,
|
||||
pageMetadata: result.pageMetadata as any,
|
||||
wcagCompliance: result.wcagCompliance as any,
|
||||
},
|
||||
});
|
||||
|
||||
// Update job status
|
||||
await prisma.scanJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update website compliance score
|
||||
await prisma.website.update({
|
||||
where: { id: websiteId },
|
||||
data: {
|
||||
lastScanAt: new Date(),
|
||||
complianceScore: result.summary.score,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Scan job completed', {
|
||||
jobId,
|
||||
resultId: scanResult.id,
|
||||
score: result.summary.score,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Scan job failed', {
|
||||
jobId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
// Update job status
|
||||
await prisma.scanJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
error: error.message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping scan worker...');
|
||||
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
this.worker = null;
|
||||
}
|
||||
|
||||
if (this.scanner) {
|
||||
await this.scanner.close();
|
||||
this.scanner = null;
|
||||
}
|
||||
|
||||
logger.info('Scan worker stopped');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue