UNPKG

blazerjob

Version:

TypeScript library for scheduling, executing, and managing asynchronous tasks (custom, HTTP) with a SQLite backend.

417 lines (384 loc) 14.5 kB
import * as dotenv from 'dotenv'; dotenv.config(); import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; // @ts-ignore const Database = require('better-sqlite3'); import fetch from 'node-fetch'; // si node <18 import * as crypto from 'crypto'; import { TaskType, TaskConfig, HttpTaskConfig } from './types'; import { makeHttpTaskFn } from './http/queries'; export interface ScheduleExtra { maxRuns?: number; maxDurationMs?: number; onEnd?: (stats: { runCount: number, errorCount: number }) => void; } export type OnTaskEnd = (taskId: number, stats: { runCount: number, errorCount: number }) => void; export type OnAllTasksEnded = () => void; export interface BlazeJobOptions { dbPath?: string; storage?: 'sqlite' | 'memory'; autoExit?: boolean; concurrency?: number; encryptionKey?: string; debug?: boolean; } const ALGORITHM = 'aes-256-gcm'; function getEncryptionKey(optionsKey?: string): Buffer { const key = optionsKey || process.env.BLAZERJOB_ENCRYPTION_KEY || 'default_blazerjob_secret_do_not_use_in_prod'; return crypto.scryptSync(key, 'salt', 32); } function encryptConfig(configStr: string | null, key: Buffer): string | null { if (!configStr) return configStr; const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); let encrypted = cipher.update(configStr, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag().toString('hex'); return `enc:v1:${iv.toString('hex')}:${authTag}:${encrypted}`; } function decryptConfig(encryptedStr: string | null, key: Buffer): string | null { if (!encryptedStr || !encryptedStr.startsWith('enc:v1:')) { return encryptedStr; } const parts = encryptedStr.split(':'); if (parts.length !== 5) return encryptedStr; const iv = Buffer.from(parts[2], 'hex'); const authTag = Buffer.from(parts[3], 'hex'); const encryptedText = parts[4]; const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } export class BlazeJob { private db: any; private timer?: NodeJS.Timeout; private encryptionKey: Buffer; // Map: taskId -> { runCount, startedAt, maxRuns, maxDurationMs } private taskRunStats = new Map<number, { runCount: number, startedAt: number, maxRuns?: number, maxDurationMs?: number, onEnd?: (stats: { runCount: number, errorCount: number }) => void, errorCount?: number }>(); private taskErrorCount = new Map<number, number>(); private taskCount = 0; private onAllTasksEndedCb?: OnAllTasksEnded; private autoExit: boolean; private concurrency: number; private debug: boolean; private activeTasksCount = 0; // Map: taskId -> taskFn (en mémoire) private taskFns = new Map<number, () => Promise<void>>(); public onAllTasksEnded(cb: OnAllTasksEnded) { this.onAllTasksEndedCb = cb; } constructor(options: BlazeJobOptions) { this.encryptionKey = getEncryptionKey(options.encryptionKey); const useMemoryStorage = options.storage !== 'sqlite'; const dbPath = useMemoryStorage ? ':memory:' : (options.dbPath || 'blazerjob.db'); this.db = new Database(dbPath); if (!useMemoryStorage) { this.db.pragma('journal_mode = WAL'); } this.autoExit = !!options.autoExit; this.concurrency = options.concurrency || 1; this.debug = !!options.debug; this.db.prepare(` CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, runAt TEXT, interval INTEGER, priority INTEGER, retriesLeft INTEGER, type TEXT, config TEXT, webhookUrl TEXT, status TEXT DEFAULT 'pending', executed_at TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, lastError TEXT ) `).run(); try { this.db.prepare('ALTER TABLE tasks ADD COLUMN lastError TEXT').run(); } catch (e) { // Ignore si déjà présent } try { this.db.prepare('ALTER TABLE tasks ADD COLUMN webhookUrl TEXT').run(); } catch (e) { // Ignore si déjà présent } } public async start() { if (!this.timer) { this.timer = setInterval(() => this.tick(), 50); } } public stop() { if (this.timer) { clearInterval(this.timer); this.timer = undefined; } } private async tick() { if (this.debug) { console.log('[BlazeJob][DEBUG] tick() called. taskCount:', this.taskCount, 'taskRunStats:', Array.from(this.taskRunStats.keys())); console.log('[BlazeJob] tick'); } type TaskRow = { id: number; runAt: string; interval: number | null; priority: number; retriesLeft: number; type: string; config: string | null; webhookUrl: string | null; status: string; executed_at: string | null; created_at: string; lastError: string | null; }; const now = new Date().toISOString(); const availableSlots = this.concurrency - this.activeTasksCount; if (availableSlots <= 0) return; const selectStmt = this.db.prepare(` SELECT * FROM tasks WHERE runAt <= @now AND status = 'pending' ORDER BY priority DESC, runAt ASC LIMIT ${availableSlots} `); const dueTasks = selectStmt.all({ now }) as TaskRow[]; if (dueTasks.length === 0) return; for (const task of dueTasks) { this.db.prepare(`UPDATE tasks SET status = 'running' WHERE id = ?`).run(task.id); this.activeTasksCount++; (async () => { const stat = this.taskRunStats.get(task.id); try { let taskFn: () => Promise<void> = async () => { }; // Decrypt config before execution if needed const decryptedConfig = decryptConfig(task.config, this.encryptionKey); // If the task has a custom JS function (stored in memory), use it if (this.taskFns.has(task.id)) { taskFn = this.taskFns.get(task.id)!; } else if (task.type === 'http' && decryptedConfig) { let config: any = decryptedConfig; // Parse multiple times if needed for (let i = 0; i < 2; i++) { if (typeof config === 'string') { try { config = JSON.parse(config); } catch (e) { console.error('[BlazeJob][HTTP] Config parsing error:', e, config); break; } } } taskFn = makeHttpTaskFn(config); } else { // Do not reassign taskFn here to let custom function execute } await taskFn(); // Après exécution de la tâche (succès ou erreur) if (stat) { stat.runCount++; // Vérifie si on a atteint maxRuns ou maxDuration if ((stat.maxRuns && stat.runCount >= stat.maxRuns) || (stat.maxDurationMs && Date.now() - stat.startedAt > stat.maxDurationMs)) { // On ne replanifiera pas (voir condition plus bas) } } const isOverMaxRuns = stat?.maxRuns && stat.runCount >= stat.maxRuns; const isOverMaxDuration = stat?.maxDurationMs && Date.now() - stat.startedAt > (stat.maxDurationMs || Infinity); if (typeof task.interval === 'number' && task.interval > 0 && !isOverMaxRuns && !isOverMaxDuration) { // Replanifier la tâche périodique const nextRunAt = new Date(Date.now() + task.interval).toISOString(); this.db.prepare(`UPDATE tasks SET status = 'pending', runAt = ? WHERE id = ?`).run(nextRunAt, task.id); } else { // Tâche terminée (succès ou fin de retry) this.db.prepare(`UPDATE tasks SET status = 'success', executed_at = ? WHERE id = ?`).run(new Date().toISOString(), task.id); if (stat && stat.onEnd) stat.onEnd({ runCount: stat.runCount, errorCount: stat.errorCount || 0 }); this.taskRunStats.delete(task.id); this.taskCount--; // console.log('[BlazeJob][DEBUG] Après suppression, taskCount:', this.taskCount, 'taskRunStats:', Array.from(this.taskRunStats.keys())); if (this.taskCount === 0 && this.onAllTasksEndedCb) this.onAllTasksEndedCb(); if (this.taskCount === 0 && this.autoExit) { // console.log('[BlazeJob][DEBUG] Condition autoExit atteinte, arrêt dans 200ms'); setTimeout(() => { try { this.stop(); } catch (e) { console.error('[BlazeJob] Erreur lors de l\'arrêt du scheduler', e); } try { if (this.db && typeof this.db.close === 'function') this.db.close(); } catch (e) { console.error('[BlazeJob] Erreur lors de la fermeture de la base', e); } console.log('[BlazeJob] Toutes les tâches périodiques sont terminées. Arrêt automatique du process.'); process.exit(0); }, 200); } } } catch (err) { console.error('[BlazeJob] Erreur lors de l\'exécution de la tâche', task.id, err); } finally { this.activeTasksCount--; } })(); } // Drain immédiatement si d'autres tâches sont prêtes et qu'il reste de la capacité, // sans attendre le prochain intervalle. if (dueTasks.length === availableSlots) { setImmediate(() => this.tick()); } } static async sendWebhook(url: string, payload: any) { try { await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } catch (e) { // Optionnel : log ou ignorer } } public getTasks(): any[] { const tasks = this.db.prepare('SELECT * FROM tasks').all(); return tasks.map((task: any) => { if (task.config) { task.config = decryptConfig(task.config, this.encryptionKey); } return task; }); } public deleteTask(taskId: number): void { this.db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId); // Clean up memory maps this.taskFns.delete(taskId); this.taskRunStats.delete(taskId); this.taskErrorCount.delete(taskId); } /** * Schedule a new task and store its function in the taskMap. * @param taskFn The function to execute for this task * @param opts Task options: runAt, interval, priority, retriesLeft, type, config, webhookUrl * @returns The inserted task ID */ public schedule( taskFn: (() => Promise<void>) | undefined, options: any & ScheduleExtra ): number { const { runAt, interval, priority, retriesLeft, type, config, webhookUrl, maxRuns, maxDurationMs, onEnd } = options; const stmt = this.db.prepare(` INSERT INTO tasks (runAt, interval, priority, retriesLeft, type, config, webhookUrl) VALUES (?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( runAt instanceof Date ? runAt.toISOString() : runAt, interval, priority, retriesLeft, type, config ? encryptConfig(JSON.stringify(config), this.encryptionKey) : null, webhookUrl ); const taskId = result.lastInsertRowid as number; // Stocke la fonction JS en mémoire pour ce taskId (si fournie) if (taskFn) { this.taskFns.set(taskId, taskFn); } this.taskRunStats.set(taskId, { runCount: 0, startedAt: Date.now(), maxRuns, maxDurationMs, onEnd, errorCount: 0 }); this.taskCount++; return taskId; } } // Variables globales pour le serveur autonome let app: FastifyInstance | null = null; let db: any = null; let jobs: BlazeJob | null = null; export async function startServer(port: number = 9000) { // Initialize Fastify server app = Fastify({ logger: true }); // Register form body parser for x-www-form-urlencoded (before declaring routes) // eslint-disable-next-line @typescript-eslint/no-var-requires app.register(require('@fastify/formbody')); // Initialize scheduler (RAM storage by default) jobs = new BlazeJob({ storage: 'memory' }); db = jobs['db']; // GET /tasks: return all scheduled tasks app.get('/tasks', async (request: FastifyRequest, reply: FastifyReply) => { const tasks = jobs!.getTasks(); reply.send(tasks); }); // POST /task: schedule a new task app.post('/task', async (request: FastifyRequest, reply: FastifyReply) => { const { runAt, interval, priority, retriesLeft, type, config, webhookUrl, maxRuns, maxDurationMs, onEnd } = (request.body as any) ?? {}; let taskFn: () => Promise<void> = async () => { }; if (type === 'http' && config) { const cfg = JSON.parse(config) as HttpTaskConfig; taskFn = makeHttpTaskFn(cfg); } else { // Default: dummy task taskFn = async () => { console.log('Task executed:', { type, config }); }; } const taskId = jobs!.schedule(taskFn, { runAt, interval, priority, retriesLeft, type, config, webhookUrl, maxRuns, maxDurationMs, onEnd }); reply.code(201).send({ id: taskId }); }); // DELETE /task/:id: delete a task by id app.delete('/task/:id', async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as { id: string }; const taskId = parseInt(id, 10); db.prepare('DELETE FROM tasks WHERE id = ?').run(id); // Clean up memory maps jobs!['taskFns'].delete(taskId); jobs!['taskRunStats'].delete(taskId); reply.code(204).send(); }); await jobs.start(); await app.listen({ port }); console.log(`Fastify server running on http://localhost:${port}`); } // Méthode utilitaire pour arrêter proprement le serveur et le scheduler export async function stopServer() { if (app) await app.close(); if (jobs) jobs.stop(); if (jobs && jobs['db']) jobs['db'].close(); console.log('Serveur et scheduler arrêtés proprement.'); } // Optionnel : gestion du signal SIGTERM/SIGINT process.on('SIGTERM', async () => { await stopServer(); process.exit(0); }); process.on('SIGINT', async () => { await stopServer(); process.exit(0); }); if (require.main === module) { startServer(9000).catch(err => { console.error(err); process.exit(1); }); }