initial wcag-ada

This commit is contained in:
Boki 2025-06-28 11:11:34 -04:00
parent 042b8cb83a
commit d52cfe7de2
112 changed files with 9069 additions and 0 deletions

View 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",
};

View 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');
}
}
}

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

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

View 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');
});

View 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');
}
}

View 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,
});

View 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;

View 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,
});
}

View 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');
});
}

View 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');
}
}